init: 初始化项目

This commit is contained in:
0264408
2026-03-10 16:26:48 +08:00
commit 57e0ef2cf6
79 changed files with 8943 additions and 0 deletions

8
web/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
web/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

10
web/.oxlintrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

6
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

9
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

48
web/README.md Normal file
View File

@@ -0,0 +1,48 @@
# web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

1
web/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
web/eslint.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

48
web/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"lucide-vue-next": "^0.577.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.11.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.50.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"naive-ui": "^2.44.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.50.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.6",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

3793
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

30
web/src/App.vue Normal file
View File

@@ -0,0 +1,30 @@
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<div class="h-screen w-full">
<router-view />
</div>
</n-config-provider>
</template>
<script setup lang="ts">
import {
zhCN,
dateZhCN,
NConfigProvider
} from 'naive-ui'
</script>
<style>
/* Reset */
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

152
web/src/layout/default.vue Normal file
View File

@@ -0,0 +1,152 @@
<template>
<n-layout class="h-screen w-full" has-sider>
<!-- 侧边栏 -->
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
:collapsed="collapsed"
show-trigger
@collapse="collapsed = true"
@expand="collapsed = false"
class="h-full"
>
<div class="flex h-[64px] items-center justify-center overflow-hidden whitespace-nowrap px-4 py-2">
<div class="flex items-center gap-2">
<div class="h-8 w-8 flex-shrink-0 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold">N</div>
<span v-show="!collapsed" class="text-lg font-bold text-slate-700 transition-opacity duration-300">Nebula</span>
</div>
</div>
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuUpdate"
/>
</n-layout-sider>
<n-layout class="h-full flex flex-col">
<!-- 顶部 Header -->
<n-layout-header bordered class="h-16 flex items-center justify-between px-6 bg-white z-10">
<!-- 左侧面包屑等 -->
<div class="flex items-center">
<!-- 可以在这里放面包屑 -->
</div>
<!-- 右侧用户菜单 -->
<div class="flex items-center gap-4">
<n-dropdown :options="userOptions" @select="handleUserSelect">
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 px-2 py-1 rounded transition-colors">
<n-avatar round size="small" class="bg-emerald-500 text-white">
A
</n-avatar>
<span class="text-sm font-medium text-slate-700">Admin</span>
</div>
</n-dropdown>
</div>
</n-layout-header>
<!-- 主要内容区域 -->
<n-layout-content content-style="padding: 24px; min-height: calc(100vh - 64px);" class="bg-gray-50/50">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</n-layout-content>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { ref, h, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
NLayout,
NLayoutSider,
NLayoutHeader,
NLayoutContent,
NMenu,
NIcon,
NButton,
NAvatar,
NDropdown,
type MenuOption
} from 'naive-ui'
import { LayoutDashboard, AppWindow, Settings, LogOut } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
function renderIcon(icon: any) {
return () => h(NIcon, null, { default: () => h(icon) })
}
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const collapsed = ref(false)
// 菜单配置
const menuOptions: MenuOption[] = [
{
label: '概览',
key: 'home',
icon: renderIcon(LayoutDashboard)
},
{
label: '应用管理', // Uncommented and updated
key: 'apps',
icon: renderIcon(AppWindow)
},
{
label: '系统设置', // Uncommented and updated
key: 'settings',
icon: renderIcon(Settings)
}
]
// 当前选中的菜单项,根据路由自动匹配
const activeKey = computed(() => {
return (route.name as string) || 'home'
})
const handleMenuUpdate = (key: string) => {
if (key === 'home') {
router.push('/')
} else {
router.push({ name: key })
}
}
// 用户下拉菜单
const userOptions = [
{
label: '退出登录',
key: 'logout',
icon: renderIcon(LogOut)
}
]
const handleUserSelect = (key: string) => {
if (key === 'logout') {
authStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

14
web/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import './style/index.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

45
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/view/auth/login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('@/layout/default.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'home',
component: () => import('@/view/home/index.vue'),
},
],
},
],
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const isAuthenticated = authStore.isAuthenticated()
if (to.meta.requiresAuth && !isAuthenticated) {
// 需要登录但未登录,跳转到登录页
next('/login')
} else if (to.path === '/login' && isAuthenticated) {
// 已登录访问登录页,跳转到首页
next('/')
} else {
next()
}
})
export default router

58
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User, TokenPair } from '@/types/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const accessToken = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
// 初始化时从 localStorage 恢复状态
const init = () => {
const storedUser = localStorage.getItem('user')
const storedAccessToken = localStorage.getItem('accessToken')
const storedRefreshToken = localStorage.getItem('refreshToken')
if (storedUser && storedAccessToken) {
user.value = JSON.parse(storedUser)
accessToken.value = storedAccessToken
refreshToken.value = storedRefreshToken
}
}
const login = (userData: User, tokens: TokenPair) => {
user.value = userData
accessToken.value = tokens.accessToken
refreshToken.value = tokens.refreshToken
// 保存到 localStorage
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('accessToken', tokens.accessToken)
localStorage.setItem('refreshToken', tokens.refreshToken)
}
const logout = () => {
user.value = null
accessToken.value = null
refreshToken.value = null
localStorage.removeItem('user')
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
}
const isAuthenticated = () => {
return !!accessToken.value
}
init()
return {
user,
accessToken,
refreshToken,
login,
logout,
isAuthenticated,
}
})

12
web/src/stores/counter.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

1
web/src/style/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

31
web/src/types/api.ts Normal file
View File

@@ -0,0 +1,31 @@
// API 类型定义
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
export interface User {
id: string
username: string
email: string
role: string
createdAt: string
updatedAt: string
}
export interface TokenPair {
accessToken: string
refreshToken: string
expiresIn: number
}
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
user: User
tokens: TokenPair
}

48
web/src/utils/api.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { ApiResponse, LoginRequest, LoginResponse } from '@/types/api'
const API_BASE = '/api'
class ApiClient {
private getAuthHeader(): HeadersInit {
const token = localStorage.getItem('accessToken')
return token ? { Authorization: `Bearer ${token}` } : {}
}
async request<T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
...options.headers,
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
}
// 认证相关
async login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
return this.request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
})
}
async getProfile(): Promise<ApiResponse<any>> {
return this.request('/auth/profile')
}
async refreshToken(refreshToken: string): Promise<ApiResponse<any>> {
return this.request('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
})
}
}
export const api = new ApiClient()

195
web/src/view/auth/login.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<!-- 背景装饰圆 -->
<div class="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div class="absolute -top-[10%] -left-[5%] w-[600px] h-[600px] bg-indigo-200 rounded-full blur-[120px] opacity-40"></div>
<div class="absolute top-[40%] -right-[5%] w-[500px] h-[500px] bg-emerald-200 rounded-full blur-[100px] opacity-40"></div>
</div>
<!-- 主卡片容器 -->
<div class="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl bg-white shadow-2xl shadow-slate-200/50">
<!-- 左侧装饰区 -->
<div class="hidden w-1/2 flex-col justify-between bg-slate-900 p-12 text-white lg:flex relative overflow-hidden">
<!-- 装饰背景 -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-black z-0"></div>
<div class="absolute top-0 right-0 w-[400px] h-[400px] bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-full blur-[80px] pointer-events-none z-0 transform translate-x-1/3 -translate-y-1/3"></div>
<!-- 内容 -->
<div class="relative z-10">
<div class="flex items-center gap-3 mb-8">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center">
<span class="font-bold text-white">N</span>
</div>
<span class="text-xl font-bold tracking-wider">NEBULA</span>
</div>
<h2 class="text-4xl font-bold leading-tight mb-6">
Manage Your Updates <br/>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">Efficiently & Securely</span>
</h2>
<p class="text-slate-400 leading-relaxed max-w-sm">
Nebula 是一个现代化的应用更新管理平台为您提供稳定高效的版本分发服务
</p>
</div>
<div class="relative z-10 mt-12 grid grid-cols-2 gap-6">
<div class="space-y-1">
<h3 class="text-2xl font-bold text-white">0.0k+</h3>
<p class="text-xs text-slate-500 uppercase tracking-wide">Active Users</p>
</div>
<div class="space-y-1">
<h3 class="text-2xl font-bold text-emerald-400">99.9%</h3>
<p class="text-xs text-slate-500 uppercase tracking-wide">Uptime</p>
</div>
</div>
</div>
<!-- 右侧表单区 -->
<div class="flex w-full flex-col justify-center bg-white p-8 lg:w-1/2 lg:p-16">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-800 lg:text-3xl">欢迎回来</h1>
<p class="mt-2 text-sm text-slate-500">请输入您的账号密码登录系统</p>
</div>
<n-form
ref="formRef"
:model="formData"
:rules="rules"
size="large"
@submit.prevent="handleSubmit"
>
<n-form-item path="username" label="用户名">
<n-input
v-model:value="formData.username"
placeholder="admin"
:disabled="loading"
@keydown.enter.prevent
>
<template #prefix>
<n-icon><User /></n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="password" label="密码">
<n-input
v-model:value="formData.password"
type="password"
show-password-on="click"
placeholder="admin123"
:disabled="loading"
@keydown.enter.prevent
@keypress.enter="handleSubmit"
>
<template #prefix>
<n-icon><Lock /></n-icon>
</template>
</n-input>
</n-form-item>
<div class="flex items-center justify-between mb-6">
<div class="text-sm text-slate-500">
<span class="cursor-pointer hover:text-emerald-600 transition-colors">忘记密码?</span>
</div>
</div>
<div v-if="error" class="mb-6">
<n-alert type="error" closable :show-icon="true" class="text-sm">
{{ error }}
</n-alert>
</div>
<n-button
type="primary"
block
size="large"
:loading="loading"
@click="handleSubmit"
class="h-12 text-base font-semibold shadow-emerald-500/20 shadow-lg"
>
</n-button>
</n-form>
<div class="mt-8 text-center text-xs text-slate-400">
默认测试账号admin / admin123
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '@/utils/api'
import { useAuthStore } from '@/stores/auth'
import {
NForm,
NFormItem,
NInput,
NButton,
NAlert,
NIcon,
type FormInst,
type FormRules
} from 'naive-ui'
import { User, Lock } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInst | null>(null)
const formData = ref({
username: '',
password: '',
})
const loading = ref(false)
const error = ref('')
const rules: FormRules = {
username: {
required: true,
message: '请输入用户名',
trigger: ['input', 'blur']
},
password: {
required: true,
message: '请输入密码',
trigger: ['input', 'blur']
}
}
const handleSubmit = async () => {
error.value = ''
try {
await formRef.value?.validate()
} catch (e) {
return
}
loading.value = true
try {
const response = await api.login(formData.value)
if (response.code === 0) {
// 保存登录状态
authStore.login(response.data.user, response.data.tokens)
// 跳转到首页
router.push('/')
} else {
error.value = response.message || '登录失败'
}
} catch (err: any) {
error.value = err.message || '网络错误,请稍后重试'
} finally {
loading.value = false
}
}
</script>

113
web/src/view/home/index.vue Normal file
View File

@@ -0,0 +1,113 @@
<template>
<div class="space-y-6">
<!-- 头部欢迎语 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-800">仪表盘 / Dashboard</h1>
<p class="text-slate-500 mt-1">欢迎回来Admin</p>
</div>
<div class="flex items-center gap-2">
<n-button type="primary" size="medium" class="px-6 rounded-lg shadow-sm">
<template #icon>
<n-icon><Plus /></n-icon>
</template>
发布新版本
</n-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- 应用总数 -->
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
<template #header>
<span class="text-sm font-medium text-slate-500">应用总数</span>
</template>
<template #header-extra>
<div class="p-2 bg-indigo-50 rounded-lg text-indigo-600">
<n-icon size="20"><Smartphone /></n-icon>
</div>
</template>
<div class="mt-2">
<span class="text-3xl font-bold text-slate-800">12</span>
<span class="ml-2 text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">+2 本周</span>
</div>
</n-card>
<!-- 活跃版本 -->
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
<template #header>
<span class="text-sm font-medium text-slate-500">活跃版本</span>
</template>
<template #header-extra>
<div class="p-2 bg-emerald-50 rounded-lg text-emerald-600">
<n-icon size="20"><Rocket /></n-icon>
</div>
</template>
<div class="mt-2">
<span class="text-3xl font-bold text-slate-800">48</span>
</div>
</n-card>
<!-- 下载次数 -->
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
<template #header>
<span class="text-sm font-medium text-slate-500">总下载量</span>
</template>
<template #header-extra>
<div class="p-2 bg-blue-50 rounded-lg text-blue-600">
<n-icon size="20"><Download /></n-icon>
</div>
</template>
<div class="mt-2">
<span class="text-3xl font-bold text-slate-800">1.2k</span>
<span class="ml-2 text-xs font-medium text-rose-600 bg-rose-50 px-2 py-0.5 rounded-full">-5% 同比</span>
</div>
</n-card>
<!-- 存储用量 -->
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
<template #header>
<span class="text-sm font-medium text-slate-500">存储用量</span>
</template>
<template #header-extra>
<div class="p-2 bg-amber-50 rounded-lg text-amber-600">
<n-icon size="20"><HardDrive /></n-icon>
</div>
</template>
<div class="mt-2">
<span class="text-3xl font-bold text-slate-800">4.2GB</span>
<span class="ml-2 text-xs font-medium text-slate-400">/ 10GB</span>
</div>
</n-card>
</div>
<!-- 最近更新表格 -->
<n-card title="最近发布动态" :bordered="false" class="rounded-xl shadow-sm mt-6">
<n-data-table
:columns="columns"
:data="data"
:bordered="false"
class="mt-4"
/>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { NCard, NButton, NDataTable, NIcon } from 'naive-ui'
import { Smartphone, Rocket, Download, HardDrive, Plus } from 'lucide-vue-next'
const data = ref([
{ app: 'Nebula App', version: 'v1.0.2', time: '2026-03-09 14:00', status: '已发布' },
{ app: 'Nebula Admin', version: 'v2.1.0', time: '2026-03-08 10:00', status: '审核中' }
])
const columns = ref([
{ title: '应用名称', key: 'app' },
{ title: '版本', key: 'version' },
{ title: '发布时间', key: 'time' },
{ title: '状态', key: 'status' }
])
</script>

18
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

11
web/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

28
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

42
web/vite.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
],
server: {
port: 9040,
open: true, // 自动打开浏览器
proxy: {
// 代理 API 请求到后端服务器
'/api': {
target: 'http://localhost:9050',
changeOrigin: true,
},
// 代理文件下载请求
'/files': {
target: 'http://localhost:9050',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
// 清理输出目录
emptyOutDir: true,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})