init: 初始化项目
This commit is contained in:
8
web/.editorconfig
Normal file
8
web/.editorconfig
Normal 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
1
web/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
web/.gitignore
vendored
Normal file
39
web/.gitignore
vendored
Normal 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
10
web/.oxlintrc.json
Normal 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
6
web/.prettierrc.json
Normal 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
9
web/.vscode/extensions.json
vendored
Normal 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
48
web/README.md
Normal 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
1
web/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
web/eslint.config.ts
Normal file
26
web/eslint.config.ts
Normal 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
13
web/index.html
Normal 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
48
web/package.json
Normal 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
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
2
web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
BIN
web/public/favicon.ico
Normal file
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
30
web/src/App.vue
Normal 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
152
web/src/layout/default.vue
Normal 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
14
web/src/main.ts
Normal 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
45
web/src/router/index.ts
Normal 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
58
web/src/stores/auth.ts
Normal 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
12
web/src/stores/counter.ts
Normal 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
1
web/src/style/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
31
web/src/types/api.ts
Normal file
31
web/src/types/api.ts
Normal 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
48
web/src/utils/api.ts
Normal 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
195
web/src/view/auth/login.vue
Normal 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
113
web/src/view/home/index.vue
Normal 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
18
web/tsconfig.app.json
Normal 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
11
web/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
web/tsconfig.node.json
Normal file
28
web/tsconfig.node.json
Normal 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
42
web/vite.config.ts
Normal 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))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user