Compare commits

...

2 Commits

Author SHA1 Message Date
MaYu
c7b0672db9 feat: 实现应用管理功能并重构API结构
- 新增应用管理页面,支持创建、查看、搜索和删除应用
- 重构API结构,将auth和app相关接口分离到独立模块
- 实现应用列表过滤和短ID生成功能
- 优化路由守卫和全局消息提示
- 更新登录页面样式和表单验证逻辑
2026-03-11 00:47:06 +08:00
MaYu
d006d205c1 Add .gitignore file and remove nebula.db from tracking 2026-03-11 00:45:32 +08:00
16 changed files with 767 additions and 272 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Go build output
dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go module files
go.sum
# Environment variables
.env
.env.dev
.env.prod
.env.example
# Database files
*.db
# IDE and editor files
.idea/
.vscode/
.trae/
*.swp
*.swo
*~
# OS generated files
.DS_Store
Thumbs.db
# Log files
*.log
logs/
# Temporary files
tmp/
temp/
# database files
nebula.db

View File

@@ -16,7 +16,17 @@ func NewAppHandler(service *app.AppService) *AppHandler {
}
func (h *AppHandler) List(c *gin.Context) {
apps, err := h.service.List()
params := make(map[string]any)
// 从查询参数中获取过滤条件
if name := c.Query("name"); name != "" {
params["name"] = name
}
if description := c.Query("description"); description != "" {
params["description"] = description
}
apps, err := h.service.List(params)
if err != nil {
response.FailServer(c, err.Error())
return

View File

@@ -1,6 +1,10 @@
package app
import "gorm.io/gorm"
import (
"nebula/pkg/util"
"gorm.io/gorm"
)
type AppService struct {
db *gorm.DB
@@ -10,9 +14,19 @@ func NewService(db *gorm.DB) *AppService {
return &AppService{db: db}
}
func (s *AppService) List() ([]App, error) {
func (s *AppService) List(params map[string]any) ([]App, error) {
var apps []App
err := s.db.Find(&apps).Error
query := s.db
// 只处理name和description参数使用模糊查询
if name, ok := params["name"]; ok {
query = query.Where("name LIKE ?", "%"+name.(string)+"%")
}
if description, ok := params["description"]; ok {
query = query.Where("description LIKE ?", "%"+description.(string)+"%")
}
err := query.Order("updated_at desc").Find(&apps).Error
return apps, err
}
@@ -26,6 +40,30 @@ func (s *AppService) Get(id string) (*App, error) {
}
func (s *AppService) Create(app App) error {
// 生成唯一的短ID作为应用ID
var appExists App
var err error
var id string
for {
// 生成短ID
id = util.GenerateShortID()
// 检查ID是否已存在
err = s.db.First(&appExists, "id = ?", id).Error
if err != nil {
// 如果错误是记录未找到说明ID可用
if err == gorm.ErrRecordNotFound {
break
}
// 其他错误直接返回
return err
}
// 如果ID已存在继续循环生成新ID
}
// 使用生成的唯一ID
app.ID = id
return s.db.Create(&app).Error
}

BIN
nebula.db

Binary file not shown.

21
pkg/util/identity.go Normal file
View File

@@ -0,0 +1,21 @@
package util
import (
"math/rand"
)
const (
// 短ID字符集纯数字
shortIDChars = "0123456789"
// 短ID长度
shortIDLength = 6
)
// GenerateShortID 生成短ID
func GenerateShortID() string {
b := make([]byte, shortIDLength)
for i := range b {
b[i] = shortIDChars[rand.Intn(len(shortIDChars))]
}
return string(b)
}

View File

@@ -1,9 +1,12 @@
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<div class="h-screen w-full">
<router-view />
</div>
<n-message-provider>
<n-dialog-provider>
<div class="h-screen w-full">
<router-view />
</div>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
@@ -11,20 +14,31 @@
import {
zhCN,
dateZhCN,
NConfigProvider
NConfigProvider,
NMessageProvider,
NDialogProvider
} 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;
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>

48
web/src/api/app/index.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { ApiResponse, App } from '@/types/api'
import { apiClient } from '@/api/client'
export interface GetAppsParams {
name?: string
description?: string
}
export interface CreateAppRequest {
name: string
description?: string
}
export interface UpdateAppRequest {
name?: string
description?: string
}
export const appApi = {
async getApps(params?: GetAppsParams): Promise<ApiResponse<App[]>> {
const queryString = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : ''
return apiClient.request<App[]>(`/apps${queryString}`)
},
async getApp(id: string): Promise<ApiResponse<App>> {
return apiClient.request<App>(`/apps/${id}`)
},
async createApp(data: CreateAppRequest): Promise<ApiResponse<App>> {
return apiClient.request<App>('/apps', {
method: 'POST',
body: JSON.stringify(data),
})
},
async updateApp(id: string, data: UpdateAppRequest): Promise<ApiResponse<App>> {
return apiClient.request<App>(`/apps/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
async deleteApp(id: string): Promise<ApiResponse<{ message: string }>> {
return apiClient.request<{ message: string }>(`/apps/${id}`, {
method: 'DELETE',
})
},
}

22
web/src/api/auth/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { ApiResponse, LoginRequest, LoginResponse, User } from '@/types/api'
import { apiClient } from '@/api/client'
export const authApi = {
async login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
return apiClient.request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
})
},
async getProfile(): Promise<ApiResponse<User>> {
return apiClient.request<User>('/auth/profile')
},
async refreshToken(refreshToken: string): Promise<ApiResponse<{ accessToken: string; refreshToken: string; expiresIn: number }>> {
return apiClient.request<{ accessToken: string; refreshToken: string; expiresIn: number }>('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
})
},
}

29
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { ApiResponse } from '@/types/api'
const API_BASE = '/api'
export 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()
}
}
export const apiClient = new ApiClient()

3
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './auth'
export * from './app'
export * from './client'

View File

@@ -20,24 +20,27 @@ const router = createRouter({
name: 'home',
component: () => import('@/view/home/index.vue'),
},
{
path: '/apps',
name: 'apps',
component: () => import('@/view/app/index.vue'),
}
],
},
],
})
// 路由守卫
router.beforeEach((to, from, next) => {
router.beforeEach((to, _from) => {
const authStore = useAuthStore()
const isAuthenticated = authStore.isAuthenticated()
if (to.meta.requiresAuth && !isAuthenticated) {
// 需要登录但未登录,跳转到登录页
next('/login')
return '/login'
} else if (to.path === '/login' && isAuthenticated) {
// 已登录访问登录页,跳转到首页
next('/')
} else {
next()
return '/'
}
})

View File

@@ -29,3 +29,11 @@ export interface LoginResponse {
user: User
tokens: TokenPair
}
export interface App {
id: string
name: string
description: string
createdAt: string
updatedAt: string
}

View File

@@ -1,48 +0,0 @@
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()

280
web/src/view/app/index.vue Normal file
View File

@@ -0,0 +1,280 @@
<template>
<div class="space-y-6">
<!-- 头部 -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-slate-800">应用管理</h1>
<p class="text-slate-500 mt-1">管理所有应用程序</p>
</div>
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<n-input
v-model:value="searchQuery"
placeholder="搜索应用名称或描述"
clearable
@input="handleSearch"
class="w-full md:w-64"
>
<template #prefix>
<n-icon :component="Search" />
</template>
</n-input>
<n-button type="primary" size="medium" class="px-6 rounded-lg shadow-sm" @click="showCreateDialog = true">
<template #icon>
<n-icon :component="Plus" />
</template>
新建应用
</n-button>
</div>
</div>
<!-- 加载状态 -->
<n-skeleton v-if="loading" animated :rows="5" class="rounded-xl" />
<!-- 错误提示 -->
<div v-else-if="error" class="rounded-xl">
<n-alert
type="error"
title="加载失败"
description="无法加载应用数据,请稍后重试"
show-icon
class="rounded-xl mb-4"
/>
<div class="flex justify-end">
<n-button size="small" @click="fetchApps">重试</n-button>
</div>
</div>
<!-- 应用卡片列表 -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<n-card
v-for="app in filteredApps"
:key="app.id"
:bordered="false"
class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300 overflow-hidden"
>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800">{{ app.name }}</h3>
<div class="flex items-center gap-2">
<n-button quaternary circle size="small">
<n-icon :component="Edit" />
</n-button>
<n-button quaternary circle size="small" type="error" @click="handleDelete(app.id, app.name)">
<n-icon :component="Trash2" />
</n-button>
</div>
</div>
</template>
<div class="space-y-4">
<p class="text-slate-600 text-sm" v-if="app.description">{{ app.description }}</p>
<p class="text-slate-400 text-sm italic" v-else>无描述</p>
<div class="pt-4 border-t border-slate-100">
<div class="flex justify-between text-xs text-slate-500">
<span>创建时间: {{ formatDate(app.createdAt) }}</span>
<span>更新时间: {{ formatDate(app.updatedAt) }}</span>
</div>
</div>
</div>
</n-card>
</div>
<!-- 空状态 -->
<div v-if="!loading && !error && apps.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<n-icon :component="FolderOpen" size="48" class="text-slate-300 mb-4" />
<h3 class="text-lg font-semibold text-slate-700 mb-2">暂无应用</h3>
<p class="text-slate-500 mb-6">您还没有创建任何应用</p>
<n-button type="primary" @click="showCreateDialog = true">
<template #icon>
<n-icon :component="Plus" />
</template>
新建应用
</n-button>
</div>
<!-- 创建应用对话框 -->
<n-modal
v-model:show="showCreateDialog"
preset="card"
title="创建新应用"
size="large"
style="width: 480px;"
>
<n-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
>
<n-form-item path="name" label="应用名称" required>
<n-input
v-model:value="createForm.name"
placeholder="请输入应用名称"
:disabled="creating"
/>
</n-form-item>
<n-form-item path="description" label="应用描述">
<n-input
v-model:value="createForm.description"
type="textarea"
placeholder="请输入应用描述"
:disabled="creating"
:rows="3"
/>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end items-center gap-2">
<n-button @click="showCreateDialog = false" :disabled="creating">
取消
</n-button>
<n-button type="primary" @click="handleCreate" :loading="creating">
创建
</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NCard, NButton, NInput, NIcon, NSkeleton, NAlert, NModal, NForm, NFormItem, useMessage, useDialog, type FormInst } from 'naive-ui'
import { Search, Plus, Edit, Trash2, FolderOpen } from 'lucide-vue-next'
import { appApi } from '@/api/app'
import type { App } from '@/types/api'
defineOptions({
name: 'AppView',
})
const message = useMessage()
const dialog = useDialog()
// 状态管理
const apps = ref<App[]>([])
const loading = ref(false)
const error = ref(false)
const searchQuery = ref('')
// 创建应用相关状态
const showCreateDialog = ref(false)
const creating = ref(false)
const createForm = ref({
name: '',
description: ''
})
const createFormRef = ref<FormInst | null>(null)
const createRules = ref({
name: {
required: true,
message: '请输入应用名称',
trigger: ['input', 'blur']
}
})
// 格式化日期
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 搜索过滤
const filteredApps = computed(() => {
if (!searchQuery.value) return apps.value
const query = searchQuery.value.toLowerCase()
return apps.value.filter(app =>
app.name.toLowerCase().includes(query) ||
(app.description && app.description.toLowerCase().includes(query))
)
})
// 获取应用列表
const fetchApps = async () => {
loading.value = true
error.value = false
try {
const response = await appApi.getApps()
apps.value = response.data
} catch (err) {
console.error('Failed to fetch apps:', err)
error.value = true
} finally {
loading.value = false
}
}
// 监听搜索查询变化
const handleSearch = () => {
// 客户端过滤不需要重新请求API
}
// 处理创建应用
const handleCreate = async () => {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
creating.value = true
const response = await appApi.createApp(createForm.value)
if (response.code === 0) {
message.success('应用创建成功')
showCreateDialog.value = false
// 清空表单
createForm.value = {
name: '',
description: ''
}
// 重新获取应用列表
await fetchApps()
} else {
message.error(response.message || '创建失败')
}
} catch (err) {
message.error(err instanceof Error ? err.message : '创建失败')
} finally {
creating.value = false
}
}
// 处理删除应用
const handleDelete = (id: string, name: string) => {
dialog.warning({
title: '确认删除',
content: `确定要删除应用 "${name}" 吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = await appApi.deleteApp(id)
if (response.code === 0) {
message.success('应用删除成功')
await fetchApps()
} else {
message.error(response.message || '删除失败')
}
} catch (err) {
message.error(err instanceof Error ? err.message : '删除失败')
}
}
})
}
// 组件挂载时获取数据
onMounted(() => {
fetchApps()
})
</script>
<style scoped>
/* 自定义样式 */
</style>

View File

@@ -2,47 +2,62 @@
<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
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="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="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="absolute inset-0 bg-linear-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-linear-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 class="flex items-center gap-3 mb-8">
<div
class="w-8 h-8 rounded-lg bg-linear-to-br from-emerald-400 to-teal-600 flex items-center justify-center"
>
<span class="font-bold text-white">N</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>
<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-linear-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 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>
@@ -54,68 +69,64 @@
</div>
<n-form
ref="formRef"
:model="formData"
:rules="rules"
size="large"
@submit.prevent="handleSubmit"
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-form-item path="username" label="用户名">
<n-input
v-model:value="formData.username"
: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"
: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-button>
</n-form>
<div class="mt-8 text-center text-xs text-slate-400">
默认测试账号admin / admin123
</div>
<div class="mt-8 text-center text-xs text-slate-400">默认测试账号admin / 123456</div>
</div>
</div>
</div>
@@ -124,20 +135,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '@/utils/api'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import {
NForm,
NFormItem,
NInput,
NButton,
NAlert,
import {
NForm,
NFormItem,
NInput,
NButton,
NAlert,
NIcon,
type FormInst,
type FormRules
type FormRules,
} from 'naive-ui'
import { User, Lock } from 'lucide-vue-next'
defineOptions({
name: 'LoginView',
})
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInst | null>(null)
@@ -154,28 +169,22 @@ const rules: FormRules = {
username: {
required: true,
message: '请输入用户名',
trigger: ['input', 'blur']
trigger: ['input', 'blur'],
},
password: {
required: true,
message: '请输入密码',
trigger: ['input', 'blur']
}
trigger: ['input', 'blur'],
},
}
const handleSubmit = async () => {
error.value = ''
try {
await formRef.value?.validate()
} catch (e) {
return
}
await formRef.value?.validate()
loading.value = true
try {
const response = await api.login(formData.value)
const response = await authApi.login(formData.value)
if (response.code === 0) {
// 保存登录状态
@@ -186,8 +195,8 @@ const handleSubmit = async () => {
} else {
error.value = response.message || '登录失败'
}
} catch (err: any) {
error.value = err.message || '网络错误,请稍后重试'
} catch (err) {
error.value = err instanceof Error ? err.message : '网络错误,请稍后重试'
} finally {
loading.value = false
}

View File

@@ -1,96 +1,108 @@
<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 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="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 class="flex items-center gap-2">
<n-button type="primary" size="medium" class="px-6 rounded-lg shadow-sm">
<template #icon>
<n-icon :component="Plus" />
</template>
发布新版本
</n-button>
</div>
</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"
/>
<!-- 统计卡片 -->
<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" :component="Smartphone" />
</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" :component="Rocket" />
</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" :component="Download" />
</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" :component="HardDrive" />
</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>
@@ -99,15 +111,19 @@ import { ref } from 'vue'
import { NCard, NButton, NDataTable, NIcon } from 'naive-ui'
import { Smartphone, Rocket, Download, HardDrive, Plus } from 'lucide-vue-next'
defineOptions({
name: 'HomeView',
})
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: '审核中' }
{ 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' }
{ title: '应用名称', key: 'app' },
{ title: '版本', key: 'version' },
{ title: '发布时间', key: 'time' },
{ title: '状态', key: 'status' },
])
</script>