Compare commits
2 Commits
23e3ef0ece
...
c7b0672db9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b0672db9 | ||
|
|
d006d205c1 |
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
21
pkg/util/identity.go
Normal file
21
pkg/util/identity.go
Normal 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)
|
||||
}
|
||||
@@ -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
48
web/src/api/app/index.ts
Normal 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
22
web/src/api/auth/index.ts
Normal 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
29
web/src/api/client.ts
Normal 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
3
web/src/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth'
|
||||
export * from './app'
|
||||
export * from './client'
|
||||
@@ -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 '/'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -29,3 +29,11 @@ export interface LoginResponse {
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -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
280
web/src/view/app/index.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user