添加数据库文件

This commit is contained in:
2025-11-11 18:20:45 +08:00
parent 96e9a6d396
commit 33e12575dd
139 changed files with 0 additions and 183904 deletions

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useUserStore } from './stores/hertz_user'
import { RouterView } from 'vue-router'
import { ConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import enUS from 'ant-design-vue/es/locale/en_US'
const userStore = useUserStore()
// 主题配置 - 简约现代风格
const theme = ref({
algorithm: 'default' as 'default' | 'dark' | 'compact',
token: {
colorPrimary: '#2563eb',
colorSuccess: '#10b981',
colorWarning: '#f59e0b',
colorError: '#ef4444',
borderRadius: 8,
fontSize: 14,
},
})
// 语言配置
const locale = ref(zhCN)
// 主题切换
const toggleTheme = () => {
const currentTheme = localStorage.getItem('theme') || 'light'
const newTheme = currentTheme === 'light' ? 'dark' : 'light'
localStorage.setItem('theme', newTheme)
if (newTheme === 'dark') {
theme.value.algorithm = 'dark'
document.documentElement.setAttribute('data-theme', 'dark')
} else {
theme.value.algorithm = 'default'
document.documentElement.setAttribute('data-theme', 'light')
}
}
// 初始化主题
onMounted(() => {
const savedTheme = localStorage.getItem('theme') || 'light'
if (savedTheme === 'dark') {
theme.value.algorithm = 'dark'
document.documentElement.setAttribute('data-theme', 'dark')
} else {
theme.value.algorithm = 'default'
document.documentElement.setAttribute('data-theme', 'light')
}
})
const showLayout = computed(() => {
return userStore.isLoggedIn
})
</script>
<template>
<div id="app">
<ConfigProvider :theme="theme" :locale="locale">
<RouterView />
</ConfigProvider>
</div>
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
}
</style>

View File

@@ -1,96 +0,0 @@
import { request } from '@/utils/hertz_request'
// 通用响应类型
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 会话与消息类型
export interface AIChatItem {
id: number
title: string
created_at: string
updated_at: string
latest_message?: string
}
export interface AIChatDetail {
id: number
title: string
created_at: string
updated_at: string
}
export interface AIChatMessage {
id: number
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface ChatListData {
total: number
page: number
page_size: number
chats: AIChatItem[]
}
export interface ChatDetailData {
chat: AIChatDetail
messages: AIChatMessage[]
}
export interface SendMessageData {
user_message: AIChatMessage
ai_message: AIChatMessage
}
// 将后端可能返回的 chat_id 统一规范为 id
const normalizeChatItem = (raw: any): AIChatItem => ({
id: typeof raw?.id === 'number' ? raw.id : Number(raw?.chat_id),
title: raw?.title,
created_at: raw?.created_at,
updated_at: raw?.updated_at,
latest_message: raw?.latest_message,
})
const normalizeChatDetail = (raw: any): AIChatDetail => ({
id: typeof raw?.id === 'number' ? raw.id : Number(raw?.chat_id),
title: raw?.title,
created_at: raw?.created_at,
updated_at: raw?.updated_at,
})
export const aiApi = {
listChats: (params?: { query?: string; page?: number; page_size?: number }): Promise<ApiResponse<ChatListData>> =>
request.get('/api/ai/chats/', { params, showError: false }).then((resp: any) => {
if (resp?.data?.chats && Array.isArray(resp.data.chats)) {
resp.data.chats = resp.data.chats.map((c: any) => normalizeChatItem(c))
}
return resp as ApiResponse<ChatListData>
}),
createChat: (body?: { title?: string }): Promise<ApiResponse<AIChatDetail>> =>
request.post('/api/ai/chats/create/', body || { title: '新对话' }).then((resp: any) => {
if (resp?.data) resp.data = normalizeChatDetail(resp.data)
return resp as ApiResponse<AIChatDetail>
}),
getChatDetail: (chatId: number): Promise<ApiResponse<ChatDetailData>> =>
request.get(`/api/ai/chats/${chatId}/`).then((resp: any) => {
if (resp?.data?.chat) resp.data.chat = normalizeChatDetail(resp.data.chat)
return resp as ApiResponse<ChatDetailData>
}),
updateChat: (chatId: number, body: { title: string }): Promise<ApiResponse<null>> =>
request.put(`/api/ai/chats/${chatId}/update/`, body),
deleteChats: (chatIds: number[]): Promise<ApiResponse<null>> =>
request.post('/api/ai/chats/delete/', { chat_ids: chatIds }),
sendMessage: (chatId: number, body: { content: string }): Promise<ApiResponse<SendMessageData>> =>
request.post(`/api/ai/chats/${chatId}/send/`, body),
}

View File

@@ -1,47 +0,0 @@
import { request } from '@/utils/hertz_request'
// 注册接口数据类型
export interface RegisterData {
username: string
password: string
confirm_password: string
email: string
phone: string
real_name: string
captcha: string
captcha_id: string
}
// 发送邮箱验证码数据类型
export interface SendEmailCodeData {
email: string
code_type: string
}
// 登录接口数据类型
export interface LoginData {
username: string
password: string
captcha_code: string
captcha_key: string
}
// 注册API
export const registerUser = (data: RegisterData) => {
return request.post('/api/auth/register/', data)
}
// 登录API
export const loginUser = (data: LoginData) => {
return request.post('/api/auth/login/', data)
}
// 发送邮箱验证码API
export const sendEmailCode = (data: SendEmailCodeData) => {
return request.post('/api/auth/email/code/', data)
}
// 登出API
export const logoutUser = () => {
return request.post('/api/auth/logout/')
}

View File

@@ -1,89 +0,0 @@
import { request } from '@/utils/hertz_request'
// 验证码相关接口类型定义
export interface CaptchaResponse {
captcha_id: string
image_data: string // base64编码的图片
expires_in: number // 过期时间(秒)
}
export interface CaptchaRefreshResponse {
captcha_id: string
image_data: string // base64编码的图片
expires_in: number // 过期时间(秒)
}
/**
* 生成验证码
*/
export const generateCaptcha = async (): Promise<CaptchaResponse> => {
console.log('🚀 开始发送验证码生成请求...')
console.log('📍 请求URL:', `${import.meta.env.VITE_API_BASE_URL}/api/captcha/generate/`)
console.log('🌐 环境变量 VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
try {
const response = await request.post<{
code: number
message: string
data: CaptchaResponse
}>('/api/captcha/generate/')
console.log('✅ 验证码生成请求成功:', response)
return response.data
} catch (error: any) {
console.error('❌ 验证码生成请求失败 - 完整错误信息:')
console.error('错误对象:', error)
console.error('错误类型:', typeof error)
console.error('错误消息:', error?.message)
console.error('错误代码:', error?.code)
console.error('错误状态:', error?.status)
console.error('错误响应:', error?.response)
console.error('错误请求:', error?.request)
console.error('错误配置:', error?.config)
// 检查是否是网络错误
if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
console.error('🌐 网络连接错误 - 可能的原因:')
console.error('1. 后端服务器未启动')
console.error('2. API地址不正确')
console.error('3. CORS配置问题')
console.error('4. 防火墙阻止连接')
}
throw error
}
}
/**
* 刷新验证码
*/
export const refreshCaptcha = async (captcha_id: string): Promise<CaptchaRefreshResponse> => {
console.log('🔄 开始发送验证码刷新请求...')
console.log('📍 请求URL:', `${import.meta.env.VITE_API_BASE_URL}/api/captcha/refresh/`)
console.log('📦 请求数据:', { captcha_id })
try {
const response = await request.post<{
code: number
message: string
data: CaptchaRefreshResponse
}>('/api/captcha/refresh/', {
captcha_id
})
console.log('✅ 验证码刷新请求成功:', response)
return response.data
} catch (error: any) {
console.error('❌ 验证码刷新请求失败 - 完整错误信息:')
console.error('错误对象:', error)
console.error('错误类型:', typeof error)
console.error('错误消息:', error?.message)
console.error('错误代码:', error?.code)
console.error('错误状态:', error?.status)
console.error('错误响应:', error?.response)
console.error('错误请求:', error?.request)
console.error('错误配置:', error?.config)
throw error
}
}

View File

@@ -1,393 +0,0 @@
import { request } from '@/utils/hertz_request'
import { logApi, type OperationLogListItem } from './log'
import { systemMonitorApi, type SystemInfo, type CpuInfo, type MemoryInfo, type DiskInfo } from './system_monitor'
import { noticeUserApi } from './notice_user'
import { knowledgeApi } from './knowledge'
// 仪表盘统计数据类型定义
export interface DashboardStats {
totalUsers: number
totalNotifications: number
totalLogs: number
totalKnowledge: number
userGrowthRate: number
notificationGrowthRate: number
logGrowthRate: number
knowledgeGrowthRate: number
}
// 最近活动数据类型
export interface RecentActivity {
id: number
action: string
time: string
user: string
type: 'login' | 'create' | 'update' | 'system' | 'register'
}
// 系统状态数据类型
export interface SystemStatus {
cpuUsage: number
memoryUsage: number
diskUsage: number
networkStatus: 'normal' | 'warning' | 'error'
}
// 访问趋势数据类型
export interface VisitTrend {
date: string
visits: number
users: number
}
// 仪表盘数据汇总类型
export interface DashboardData {
stats: DashboardStats
recentActivities: RecentActivity[]
systemStatus: SystemStatus
visitTrends: VisitTrend[]
}
// API响应类型
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 仪表盘API接口
export const dashboardApi = {
// 获取仪表盘统计数据
getStats: (): Promise<ApiResponse<DashboardStats>> => {
return request.get('/api/dashboard/stats/')
},
// 获取真实统计数据
getRealStats: async (): Promise<ApiResponse<DashboardStats>> => {
try {
// 并行获取各种统计数据
const [notificationStats, logStats, knowledgeStats] = await Promise.all([
noticeUserApi.statistics().catch(() => ({ success: false, data: { total_count: 0, unread_count: 0 } })),
logApi.getList({ page: 1, page_size: 1 }).catch(() => ({ success: false, data: { count: 0 } })),
knowledgeApi.getArticles({ page: 1, page_size: 1 }).catch(() => ({ success: false, data: { total: 0 } }))
])
// 计算统计数据
const totalNotifications = notificationStats.success ? (notificationStats.data.total_count || 0) : 0
// 处理日志数据 - 兼容多种返回结构
let totalLogs = 0
if (logStats.success && logStats.data) {
const logData = logStats.data as any
console.log('日志API响应数据:', logData)
// 兼容DRF标准结构{ count, next, previous, results }
if ('count' in logData) {
totalLogs = Number(logData.count) || 0
} else if ('total' in logData) {
totalLogs = Number(logData.total) || 0
} else if ('total_count' in logData) {
totalLogs = Number(logData.total_count) || 0
} else if (logData.pagination && logData.pagination.total_count) {
totalLogs = Number(logData.pagination.total_count) || 0
}
console.log('解析出的日志总数:', totalLogs)
} else {
console.log('日志API调用失败:', logStats)
}
const totalKnowledge = knowledgeStats.success ? (knowledgeStats.data.total || 0) : 0
console.log('统计数据汇总:', { totalNotifications, totalLogs, totalKnowledge })
// 模拟增长率(实际项目中应该从后端获取)
const stats: DashboardStats = {
totalUsers: 0, // 暂时设为0需要用户管理API
totalNotifications,
totalLogs,
totalKnowledge,
userGrowthRate: 0,
notificationGrowthRate: Math.floor(Math.random() * 20) - 10, // 模拟 -10% 到 +10%
logGrowthRate: Math.floor(Math.random() * 30) - 15, // 模拟 -15% 到 +15%
knowledgeGrowthRate: Math.floor(Math.random() * 25) - 12 // 模拟 -12% 到 +13%
}
return {
success: true,
code: 200,
message: 'success',
data: stats
}
} catch (error) {
console.error('获取真实统计数据失败:', error)
return {
success: false,
code: 500,
message: '获取统计数据失败',
data: {
totalUsers: 0,
totalNotifications: 0,
totalLogs: 0,
totalKnowledge: 0,
userGrowthRate: 0,
notificationGrowthRate: 0,
logGrowthRate: 0,
knowledgeGrowthRate: 0
}
}
}
},
// 获取最近活动(从日志接口)
getRecentActivities: async (limit: number = 10): Promise<ApiResponse<RecentActivity[]>> => {
try {
const response = await logApi.getList({ page: 1, page_size: limit })
if (response.success && response.data) {
// 根据实际API响应结构数据可能在data.logs或data.results中
const logs = (response.data as any).logs || (response.data as any).results || []
const activities: RecentActivity[] = logs.map((log: any) => ({
id: log.log_id || log.id,
action: log.description || log.operation_description || `${log.action_type_display || log.operation_type} - ${log.module || log.operation_module}`,
time: formatTimeAgo(log.created_at),
user: log.username || log.user?.username || '未知用户',
type: mapLogTypeToActivityType(log.action_type || log.operation_type)
}))
return {
success: true,
code: 200,
message: 'success',
data: activities
}
}
return {
success: false,
code: 500,
message: '获取活动数据失败',
data: []
}
} catch (error) {
console.error('获取最近活动失败:', error)
return {
success: false,
code: 500,
message: '获取活动数据失败',
data: []
}
}
},
// 获取系统状态(从系统监控接口)
getSystemStatus: async (): Promise<ApiResponse<SystemStatus>> => {
try {
const [cpuResponse, memoryResponse, disksResponse] = await Promise.all([
systemMonitorApi.getCpu(),
systemMonitorApi.getMemory(),
systemMonitorApi.getDisks()
])
if (cpuResponse.success && memoryResponse.success && disksResponse.success) {
// 根据实际API响应结构映射数据
const systemStatus: SystemStatus = {
// CPU使用率从 cpu_percent 字段获取
cpuUsage: Math.round(cpuResponse.data.cpu_percent || 0),
// 内存使用率:从 percent 字段获取
memoryUsage: Math.round(memoryResponse.data.percent || 0),
// 磁盘使用率:从磁盘数组的第一个磁盘的 percent 字段获取
diskUsage: disksResponse.data.length > 0 ? Math.round(disksResponse.data[0].percent || 0) : 0,
networkStatus: 'normal' as const
}
return {
success: true,
code: 200,
message: 'success',
data: systemStatus
}
}
return {
success: false,
code: 500,
message: '获取系统状态失败',
data: {
cpuUsage: 0,
memoryUsage: 0,
diskUsage: 0,
networkStatus: 'error' as const
}
}
} catch (error) {
console.error('获取系统状态失败:', error)
return {
success: false,
code: 500,
message: '获取系统状态失败',
data: {
cpuUsage: 0,
memoryUsage: 0,
diskUsage: 0,
networkStatus: 'error' as const
}
}
}
},
// 获取访问趋势
getVisitTrends: (period: 'week' | 'month' | 'year' = 'week'): Promise<ApiResponse<VisitTrend[]>> => {
return request.get('/api/dashboard/visit-trends/', { params: { period } })
},
// 获取完整仪表盘数据
getDashboardData: (): Promise<ApiResponse<DashboardData>> => {
return request.get('/api/dashboard/overview/')
},
// 模拟数据方法(用于开发阶段)
getMockStats: (): Promise<DashboardStats> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
totalUsers: 1128,
todayVisits: 893,
totalOrders: 234,
totalRevenue: 12560.50,
userGrowthRate: 12,
visitGrowthRate: 8,
orderGrowthRate: -3,
revenueGrowthRate: 15
})
}, 500)
})
},
getMockActivities: (): Promise<RecentActivity[]> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
id: 1,
action: '用户 张三 登录了系统',
time: '2分钟前',
user: '张三',
type: 'login'
},
{
id: 2,
action: '管理员 李四 创建了新部门',
time: '5分钟前',
user: '李四',
type: 'create'
},
{
id: 3,
action: '用户 王五 修改了个人信息',
time: '10分钟前',
user: '王五',
type: 'update'
},
{
id: 4,
action: '系统自动备份完成',
time: '1小时前',
user: '系统',
type: 'system'
},
{
id: 5,
action: '新用户 赵六 注册成功',
time: '2小时前',
user: '赵六',
type: 'register'
}
])
}, 300)
})
},
getMockSystemStatus: (): Promise<SystemStatus> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
cpuUsage: 45,
memoryUsage: 67,
diskUsage: 32,
networkStatus: 'normal'
})
}, 200)
})
},
getMockVisitTrends: (period: 'week' | 'month' | 'year' = 'week'): Promise<VisitTrend[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const data = {
week: [
{ date: '周一', visits: 120, users: 80 },
{ date: '周二', visits: 150, users: 95 },
{ date: '周三', visits: 180, users: 110 },
{ date: '周四', visits: 200, users: 130 },
{ date: '周五', visits: 250, users: 160 },
{ date: '周六', visits: 180, users: 120 },
{ date: '周日', visits: 160, users: 100 }
],
month: [
{ date: '第1周', visits: 800, users: 500 },
{ date: '第2周', visits: 950, users: 600 },
{ date: '第3周', visits: 1100, users: 700 },
{ date: '第4周', visits: 1200, users: 750 }
],
year: [
{ date: '1月', visits: 3200, users: 2000 },
{ date: '2月', visits: 3800, users: 2400 },
{ date: '3月', visits: 4200, users: 2600 },
{ date: '4月', visits: 3900, users: 2300 },
{ date: '5月', visits: 4500, users: 2800 },
{ date: '6月', visits: 5000, users: 3100 }
]
}
resolve(data[period])
}, 400)
})
}
}
// 辅助函数:格式化时间为相对时间
function formatTimeAgo(dateString: string): string {
const now = new Date()
const date = new Date(dateString)
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return `${diffInSeconds}秒前`
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}分钟前`
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}小时前`
} else {
const days = Math.floor(diffInSeconds / 86400)
return `${days}天前`
}
}
// 辅助函数:将日志操作类型映射为活动类型
function mapLogTypeToActivityType(operationType: string): RecentActivity['type'] {
if (!operationType) return 'system'
const lowerType = operationType.toLowerCase()
if (lowerType.includes('login') || lowerType.includes('登录')) {
return 'login'
} else if (lowerType.includes('create') || lowerType.includes('创建') || lowerType.includes('add') || lowerType.includes('新增')) {
return 'create'
} else if (lowerType.includes('update') || lowerType.includes('修改') || lowerType.includes('edit') || lowerType.includes('更新')) {
return 'update'
} else if (lowerType.includes('register') || lowerType.includes('注册')) {
return 'register'
} else if (lowerType.includes('view') || lowerType.includes('查看') || lowerType.includes('get') || lowerType.includes('获取')) {
return 'system'
} else {
return 'system'
}
}

View File

@@ -1,93 +0,0 @@
import { request } from '@/utils/hertz_request'
// 部门数据类型定义
export interface Department {
dept_id: number
parent_id: number | null
dept_name: string
dept_code: string
leader: string
phone: string | null
email: string | null
status: number
sort_order: number
created_at: string
updated_at: string
children?: Department[]
user_count?: number
}
// API响应类型
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 部门列表数据类型
export interface DepartmentListData {
list: Department[]
total: number
page: number
page_size: number
}
export type DepartmentListResponse = ApiResponse<DepartmentListData>
// 部门列表查询参数
export interface DepartmentListParams {
page?: number
page_size?: number
search?: string
status?: number
parent_id?: number
}
// 创建部门参数
export interface CreateDepartmentParams {
parent_id: null
dept_name: string
dept_code: string
leader: string
phone: string
email: string
status: number
sort_order: number
}
// 更新部门参数
export type UpdateDepartmentParams = Partial<CreateDepartmentParams>
// 部门API接口
export const departmentApi = {
// 获取部门列表
getDepartmentList: (params?: DepartmentListParams): Promise<ApiResponse<Department[]>> => {
return request.get('/api/departments/', { params })
},
// 获取部门详情
getDepartment: (id: number): Promise<ApiResponse<Department>> => {
return request.get(`/api/departments/${id}/`)
},
// 创建部门
createDepartment: (data: CreateDepartmentParams): Promise<ApiResponse<Department>> => {
return request.post('/api/departments/create/', data)
},
// 更新部门
updateDepartment: (id: number, data: UpdateDepartmentParams): Promise<ApiResponse<Department>> => {
return request.put(`/api/departments/${id}/update/`, data)
},
// 删除部门
deleteDepartment: (id: number): Promise<ApiResponse<any>> => {
return request.delete(`/api/departments/${id}/delete/`)
},
// 获取部门树
getDepartmentTree: (): Promise<ApiResponse<Department[]>> => {
return request.get('/api/departments/tree/')
}
}

View File

@@ -1,16 +0,0 @@
// API 统一出口文件
export * from './captcha'
export * from './auth'
export * from './user'
export * from './department'
export * from './menu'
export * from './role'
export * from './password'
export * from './system_monitor'
export * from './dashboard'
export * from './ai'
// 这里可以继续添加其它 API 模块的导出,例如:
// export * from './admin'
export * from './log'
export * from './knowledge'

View File

@@ -1,173 +0,0 @@
import { request } from '@/utils/hertz_request'
// 通用响应结构
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 分类类型
export interface KnowledgeCategory {
id: number
name: string
description?: string
parent?: number | null
parent_name?: string | null
sort_order?: number
is_active?: boolean
created_at?: string
updated_at?: string
children_count?: number
articles_count?: number
full_path?: string
children?: KnowledgeCategory[]
}
export interface CategoryListData {
list: KnowledgeCategory[]
total: number
page: number
page_size: number
}
export interface CategoryListParams {
page?: number
page_size?: number
name?: string
parent_id?: number
is_active?: boolean
}
// 文章类型
export interface KnowledgeArticleListItem {
id: number
title: string
summary?: string | null
image?: string | null
category_name: string
author_name: string
status: 'draft' | 'published' | 'archived'
status_display: string
view_count?: number
created_at: string
updated_at: string
published_at?: string | null
}
export interface KnowledgeArticleDetail extends KnowledgeArticleListItem {
content: string
category: number
author: number
tags?: string
tags_list?: string[]
sort_order?: number
}
export interface ArticleListData {
list: KnowledgeArticleListItem[]
total: number
page: number
page_size: number
}
export interface ArticleListParams {
page?: number
page_size?: number
title?: string
category_id?: number
author_id?: number
status?: 'draft' | 'published' | 'archived'
tags?: string
}
export interface CreateArticlePayload {
title: string
content: string
summary?: string
image?: string
category: number
status?: 'draft' | 'published'
tags?: string
sort_order?: number
}
export interface UpdateArticlePayload {
title?: string
content?: string
summary?: string
image?: string
category?: number
status?: 'draft' | 'published' | 'archived'
tags?: string
sort_order?: number
}
// 知识库 API
export const knowledgeApi = {
// 分类:列表
getCategories: (params?: CategoryListParams): Promise<ApiResponse<CategoryListData>> => {
return request.get('/api/wiki/categories/', { params })
},
// 分类:树形
getCategoryTree: (): Promise<ApiResponse<KnowledgeCategory[]>> => {
return request.get('/api/wiki/categories/tree/')
},
// 分类:详情
getCategory: (id: number): Promise<ApiResponse<KnowledgeCategory>> => {
return request.get(`/api/wiki/categories/${id}/`)
},
// 分类:创建
createCategory: (data: Partial<KnowledgeCategory>): Promise<ApiResponse<KnowledgeCategory>> => {
return request.post('/api/wiki/categories/create/', data)
},
// 分类:更新
updateCategory: (id: number, data: Partial<KnowledgeCategory>): Promise<ApiResponse<KnowledgeCategory>> => {
return request.put(`/api/wiki/categories/${id}/update/`, data)
},
// 分类:删除
deleteCategory: (id: number): Promise<ApiResponse<null>> => {
return request.delete(`/api/wiki/categories/${id}/delete/`)
},
// 文章:列表
getArticles: (params?: ArticleListParams): Promise<ApiResponse<ArticleListData>> => {
return request.get('/api/wiki/articles/', { params })
},
// 文章:详情
getArticle: (id: number): Promise<ApiResponse<KnowledgeArticleDetail>> => {
return request.get(`/api/wiki/articles/${id}/`)
},
// 文章:创建
createArticle: (data: CreateArticlePayload): Promise<ApiResponse<KnowledgeArticleDetail>> => {
return request.post('/api/wiki/articles/create/', data)
},
// 文章:更新
updateArticle: (id: number, data: UpdateArticlePayload): Promise<ApiResponse<KnowledgeArticleDetail>> => {
return request.put(`/api/wiki/articles/${id}/update/`, data)
},
// 文章:删除
deleteArticle: (id: number): Promise<ApiResponse<null>> => {
return request.delete(`/api/wiki/articles/${id}/delete/`)
},
// 文章:发布
publishArticle: (id: number): Promise<ApiResponse<null>> => {
return request.post(`/api/wiki/articles/${id}/publish/`)
},
// 文章:归档
archiveArticle: (id: number): Promise<ApiResponse<null>> => {
return request.post(`/api/wiki/articles/${id}/archive/`)
},
}

View File

@@ -1,110 +0,0 @@
import { request } from '@/utils/hertz_request'
// 通用 API 响应结构
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 列表查询参数
export interface LogListParams {
page?: number
page_size?: number
user_id?: number
operation_type?: string
operation_module?: string
start_date?: string // YYYY-MM-DD
end_date?: string // YYYY-MM-DD
ip_address?: string
status?: number
// 新增:按请求方法与路径、关键字筛选(与后端保持可选兼容)
request_method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string
request_path?: string
keyword?: string
}
// 列表项(精简字段)
export interface OperationLogItem {
id: number
user?: {
id: number
username: string
email?: string
} | null
operation_type: string
// 展示字段
action_type_display?: string
operation_module: string
operation_description?: string
target_model?: string
target_object_id?: string
ip_address?: string
request_method: string
request_path: string
response_status: number
// 结果与状态展示
status_display?: string
is_success?: boolean
execution_time?: number
created_at: string
}
// 列表响应 data 结构
export interface LogListData {
count: number
next: string | null
previous: string | null
results: OperationLogItem[]
}
export type LogListResponse = ApiResponse<LogListData>
// 详情数据(完整字段)
export interface OperationLogDetail {
id: number
user?: {
id: number
username: string
email?: string
} | null
operation_type: string
action_type_display?: string
operation_module: string
operation_description: string
target_model?: string
target_object_id?: string
ip_address?: string
user_agent?: string
request_method: string
request_path: string
request_data?: Record<string, any>
response_status: number
status_display?: string
is_success?: boolean
response_data?: Record<string, any>
execution_time?: number
created_at: string
updated_at?: string
}
export type LogDetailResponse = ApiResponse<OperationLogDetail>
export const logApi = {
// 获取操作日志列表
getList: (params: LogListParams, options?: { signal?: AbortSignal }): Promise<LogListResponse> => {
// 关闭统一错误弹窗,由页面自行处理
return request.get('/api/log/list/', { params, showError: false, signal: options?.signal })
},
// 获取操作日志详情
getDetail: (logId: number): Promise<LogDetailResponse> => {
return request.get(`/api/log/detail/${logId}/`)
},
// 兼容查询参数方式的详情(部分后端实现为 /api/log/detail/?id=xx 或 ?log_id=xx
getDetailByQuery: (logId: number): Promise<LogDetailResponse> => {
return request.get('/api/log/detail/', { params: { id: logId, log_id: logId } })
},
}

View File

@@ -1,361 +0,0 @@
import { request } from '@/utils/hertz_request'
// 后端返回的原始菜单数据格式
export interface RawMenu {
menu_id: number
menu_name: string
menu_code: string
menu_type: number // 后端返回数字1=菜单, 2=按钮, 3=接口
parent_id?: number | null
path?: string
component?: string | null
icon?: string
permission?: string
sort_order?: number
description?: string
status?: number
is_external?: boolean
is_cache?: boolean
is_visible?: boolean
created_at?: string
updated_at?: string
children?: RawMenu[]
}
// 前端使用的菜单接口类型定义
export interface Menu {
menu_id: number
menu_name: string
menu_code: string
menu_type: number // 1=菜单, 2=按钮, 3=接口
parent_id?: number
path?: string
component?: string
icon?: string
permission?: string
sort_order?: number
status?: number
is_external?: boolean
is_cache?: boolean
is_visible?: boolean
created_at?: string
updated_at?: string
children?: Menu[]
}
// API响应基础结构
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 菜单列表数据结构
export interface MenuListData {
list: Menu[]
total: number
page: number
page_size: number
}
// 菜单列表响应类型
export type MenuListResponse = ApiResponse<MenuListData>
// 菜单列表查询参数
export interface MenuListParams {
page?: number
page_size?: number
search?: string
status?: number
menu_type?: string
parent_id?: number
}
// 创建菜单参数
export interface CreateMenuParams {
menu_name: string
menu_code: string
menu_type: number // 1=菜单, 2=按钮, 3=接口
parent_id?: number
path?: string
component?: string
icon?: string
permission?: string
sort_order?: number
status?: number
is_external?: boolean
is_cache?: boolean
is_visible?: boolean
}
// 更新菜单参数
export type UpdateMenuParams = Partial<CreateMenuParams>
// 菜单树响应类型
export type MenuTreeResponse = ApiResponse<Menu[]>
// 数据转换工具函数
const convertMenuType = (type: number): 'menu' | 'button' | 'api' => {
switch (type) {
case 1: return 'menu'
case 2: return 'button'
case 3: return 'api'
default: return 'menu'
}
}
// 解码Unicode字符串
const decodeUnicode = (str: string): string => {
try {
return str.replace(/\\u[\dA-F]{4}/gi, (match) => {
return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16))
})
} catch (error) {
return str
}
}
// 转换原始菜单数据为前端格式
const transformRawMenu = (rawMenu: RawMenu): Menu => {
// 确保status字段被正确转换
let statusValue: number
if (rawMenu.status === undefined || rawMenu.status === null) {
// 如果status缺失默认为启用1
statusValue = 1
} else {
// 如果有值,转换为数字
if (typeof rawMenu.status === 'string') {
const parsed = parseInt(rawMenu.status, 10)
statusValue = isNaN(parsed) ? 1 : parsed
} else {
statusValue = Number(rawMenu.status)
// 如果转换失败,默认为启用
if (isNaN(statusValue)) {
statusValue = 1
}
}
}
return {
menu_id: rawMenu.menu_id,
menu_name: decodeUnicode(rawMenu.menu_name),
menu_code: rawMenu.menu_code,
menu_type: rawMenu.menu_type,
parent_id: rawMenu.parent_id || undefined,
path: rawMenu.path,
component: rawMenu.component,
icon: rawMenu.icon,
permission: rawMenu.permission,
sort_order: rawMenu.sort_order,
status: statusValue, // 使用转换后的值
is_external: rawMenu.is_external,
is_cache: rawMenu.is_cache,
is_visible: rawMenu.is_visible,
created_at: rawMenu.created_at,
updated_at: rawMenu.updated_at,
children: rawMenu.children ? rawMenu.children.map(transformRawMenu) : []
}
}
// 将菜单数据数组转换为列表格式
const transformToMenuList = (rawMenus: RawMenu[]): MenuListData => {
const transformedMenus = rawMenus.map(transformRawMenu)
// 递归收集所有菜单项
const collectAllMenus = (menu: Menu): Menu[] => {
const result = [menu]
if (menu.children && menu.children.length > 0) {
menu.children.forEach(child => {
result.push(...collectAllMenus(child))
})
}
return result
}
// 收集所有菜单项
const allMenus: Menu[] = []
transformedMenus.forEach(menu => {
allMenus.push(...collectAllMenus(menu))
})
return {
list: allMenus,
total: allMenus.length,
page: 1,
page_size: allMenus.length
}
}
// 构建菜单树结构
const buildMenuTree = (rawMenus: RawMenu[]): Menu[] => {
const transformedMenus = rawMenus.map(transformRawMenu)
// 创建菜单映射
const menuMap = new Map<number, Menu>()
transformedMenus.forEach(menu => {
menuMap.set(menu.menu_id, { ...menu, children: [] })
})
// 构建树结构
const rootMenus: Menu[] = []
transformedMenus.forEach(menu => {
const menuItem = menuMap.get(menu.menu_id)!
if (menu.parent_id && menuMap.has(menu.parent_id)) {
const parent = menuMap.get(menu.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(menuItem)
} else {
rootMenus.push(menuItem)
}
})
return rootMenus
}
// 菜单API
export const menuApi = {
// 获取菜单列表
getMenuList: async (params?: MenuListParams): Promise<MenuListResponse> => {
try {
const response = await request.get<ApiResponse<RawMenu[]>>('/api/menus/', { params })
if (response.success && response.data && Array.isArray(response.data)) {
const menuListData = transformToMenuList(response.data)
return {
success: true,
code: response.code,
message: response.message,
data: menuListData
}
}
return {
success: false,
code: response.code || 500,
message: response.message || '获取菜单数据失败',
data: {
list: [],
total: 0,
page: 1,
page_size: 10
}
}
} catch (error) {
console.error('获取菜单列表失败:', error)
return {
success: false,
code: 500,
message: '网络请求失败',
data: {
list: [],
total: 0,
page: 1,
page_size: 10
}
}
}
},
// 获取菜单树
getMenuTree: async (): Promise<MenuTreeResponse> => {
try {
const response = await request.get<ApiResponse<RawMenu[]>>('/api/menus/tree/')
if (response.success && response.data && Array.isArray(response.data)) {
// 调试检查原始数据中的status值
if (response.data.length > 0) {
console.log('🔍 原始菜单数据status检查前5条:', response.data.slice(0, 5).map((m: RawMenu) => ({
menu_name: m.menu_name,
menu_id: m.menu_id,
status: m.status,
statusType: typeof m.status
})))
}
// 后端已经返回树形结构,直接转换数据格式即可
const transformedData = response.data.map(transformRawMenu)
// 调试检查转换后的status值
if (transformedData.length > 0) {
console.log('🔍 转换后菜单数据status检查前5条:', transformedData.slice(0, 5).map((m: Menu) => ({
menu_name: m.menu_name,
menu_id: m.menu_id,
status: m.status,
statusType: typeof m.status
})))
}
return {
success: true,
code: response.code,
message: response.message,
data: transformedData
}
}
return {
success: false,
code: response.code || 500,
message: response.message || '获取菜单树失败',
data: []
}
} catch (error) {
console.error('获取菜单树失败:', error)
return {
success: false,
code: 500,
message: '网络请求失败',
data: []
}
}
},
// 获取单个菜单
getMenu: async (id: number): Promise<ApiResponse<Menu>> => {
try {
const response = await request.get<ApiResponse<RawMenu>>(`/api/menus/${id}/`)
if (response.success && response.data) {
const transformedMenu = transformRawMenu(response.data)
return {
success: true,
code: response.code,
message: response.message,
data: transformedMenu
}
}
return response as ApiResponse<Menu>
} catch (error) {
console.error('获取菜单详情失败:', error)
return {
success: false,
code: 500,
message: '网络请求失败',
data: {} as Menu
}
}
},
// 创建菜单
createMenu: (data: CreateMenuParams): Promise<ApiResponse<Menu>> => {
return request.post('/api/menus/create/', data)
},
// 更新菜单
updateMenu: (id: number, data: UpdateMenuParams): Promise<ApiResponse<Menu>> => {
return request.put(`/api/menus/${id}/update/`, data)
},
// 删除菜单
deleteMenu: (id: number): Promise<ApiResponse<any>> => {
return request.delete(`/api/menus/${id}/delete/`)
},
// 批量删除菜单
batchDeleteMenus: (ids: number[]): Promise<ApiResponse<any>> => {
return request.post('/api/menus/batch-delete/', { menu_ids: ids })
}
}

View File

@@ -1,87 +0,0 @@
import { request } from '@/utils/hertz_request'
// 用户端通知模块 API 类型定义
export interface UserNoticeListItem {
notice: number
title: string
notice_type_display: string
priority_display: string
is_top: boolean
publish_time: string
is_read: boolean
read_time: string | null
is_starred: boolean
starred_time: string | null
is_expired: boolean
created_at: string
}
export interface UserNoticeListData {
notices: UserNoticeListItem[]
pagination: {
current_page: number
page_size: number
total_pages: number
total_count: number
has_next: boolean
has_previous: boolean
}
statistics: {
total_count: number
unread_count: number
starred_count: number
}
}
export interface ApiResponse<T = any> {
success: boolean
code: number
message: string
data: T
}
export interface UserNoticeDetailData {
notice: number
title: string
content: string
notice_type_display: string
priority_display: string
attachment_url: string | null
publish_time: string
expire_time: string
is_top: boolean
is_expired: boolean
publisher_name: string | null
is_read: boolean
read_time: string
is_starred: boolean
starred_time: string | null
created_at: string
updated_at: string
}
export const noticeUserApi = {
// 查看通知列表
list: (params?: { page?: number; page_size?: number }): Promise<ApiResponse<UserNoticeListData>> =>
request.get('/api/notice/user/list/', { params }),
// 查看通知详情
detail: (notice_id: number | string): Promise<ApiResponse<UserNoticeDetailData>> =>
request.get(`/api/notice/user/detail/${notice_id}/`),
// 标记通知已读
markRead: (notice_id: number | string): Promise<ApiResponse<null>> =>
request.post('/api/notice/user/mark-read/', { notice_id }),
// 批量标记通知已读
batchMarkRead: (notice_ids: Array<number | string>): Promise<ApiResponse<{ updated_count: number }>> =>
request.post('/api/notice/user/batch-mark-read/', { notice_ids }),
// 用户获取通知统计
statistics: (): Promise<ApiResponse<{ total_count: number; unread_count: number; read_count: number; starred_count: number; type_statistics?: Record<string, number>; priority_statistics?: Record<string, number> }>> =>
request.get('/api/notice/user/statistics/'),
// 收藏/取消收藏通知
toggleStar: (notice_id: number | string, is_starred: boolean): Promise<ApiResponse<null>> =>
request.post('/api/notice/user/toggle-star/', { notice_id, is_starred }),
}

View File

@@ -1,31 +0,0 @@
import { request } from '@/utils/hertz_request'
// 修改密码接口参数
export interface ChangePasswordParams {
old_password: string
new_password: string
confirm_password: string
}
// 重置密码接口参数
export interface ResetPasswordParams {
email: string
email_code: string
new_password: string
confirm_password: string
}
// 修改密码
export const changePassword = (params: ChangePasswordParams) => {
return request.post('/api/auth/password/change/', params)
}
// 重置密码
export const resetPassword = (params: ResetPasswordParams) => {
return request.post('/api/auth/password/reset/', params)
}
// 发送重置密码邮箱验证码
export const sendResetPasswordCode = (email: string) => {
return request.post('/api/auth/password/reset/code/', { email })
}

View File

@@ -1,130 +0,0 @@
import { request } from '@/utils/hertz_request'
// 权限接口类型定义
export interface Permission {
permission_id: number
permission_name: string
permission_code: string
permission_type: 'menu' | 'button' | 'api'
parent_id?: number
path?: string
icon?: string
sort_order?: number
description?: string
status?: number
children?: Permission[]
}
// 角色接口类型定义
export interface Role {
role_id: number
role_name: string
role_code: string
description?: string
status?: number
created_at?: string
updated_at?: string
permissions?: Permission[]
}
// API响应基础结构
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 角色列表数据结构
export interface RoleListData {
list: Role[]
total: number
page: number
page_size: number
}
// 角色列表响应类型
export type RoleListResponse = ApiResponse<RoleListData>
// 角色列表查询参数
export interface RoleListParams {
page?: number
page_size?: number
search?: string
status?: number
}
// 创建角色参数
export interface CreateRoleParams {
role_name: string
role_code: string
description?: string
status?: number
}
// 更新角色参数
export type UpdateRoleParams = Partial<CreateRoleParams>
// 角色权限分配参数
export interface AssignRolePermissionsParams {
role_id: number
menu_ids: number[]
user_type?: number
department_id?: number
}
// 权限列表响应类型
export type PermissionListResponse = ApiResponse<Permission[]>
// 角色API
export const roleApi = {
// 获取角色列表
getRoleList: (params?: RoleListParams): Promise<RoleListResponse> => {
return request.get('/api/roles/', { params })
},
// 获取单个角色
getRole: (id: number): Promise<ApiResponse<Role>> => {
return request.get(`/api/roles/${id}/`)
},
// 创建角色
createRole: (data: CreateRoleParams): Promise<ApiResponse<Role>> => {
return request.post('/api/roles/create/', data)
},
// 更新角色
updateRole: (id: number, data: UpdateRoleParams): Promise<ApiResponse<Role>> => {
return request.put(`/api/roles/${id}/update/`, data)
},
// 删除角色
deleteRole: (id: number): Promise<ApiResponse<any>> => {
return request.delete(`/api/roles/${id}/delete/`)
},
// 批量删除角色
batchDeleteRoles: (ids: number[]): Promise<ApiResponse<any>> => {
return request.post('/api/roles/batch-delete/', { role_ids: ids })
},
// 获取角色权限
getRolePermissions: (id: number): Promise<ApiResponse<Permission[]>> => {
return request.get(`/api/roles/${id}/menus/`)
},
// 分配角色权限
assignRolePermissions: (data: AssignRolePermissionsParams): Promise<ApiResponse<any>> => {
return request.post(`/api/roles/assign-menus/`, data)
},
// 获取所有权限列表
getPermissionList: (): Promise<PermissionListResponse> => {
return request.get('/api/menus/')
},
// 获取权限树
getPermissionTree: (): Promise<PermissionListResponse> => {
return request.get('/api/menus/tree/')
}
}

View File

@@ -1,114 +0,0 @@
import { request } from '@/utils/hertz_request'
// 通用响应类型
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 1. 系统信息
export interface SystemInfo {
hostname: string
platform: string
architecture: string
boot_time: string
uptime: string
}
// 2. CPU 信息
export interface CpuInfo {
cpu_count: number
cpu_percent: number
cpu_freq: {
current: number
min: number
max: number
}
load_avg: number[]
}
// 3. 内存信息
export interface MemoryInfo {
total: number
available: number
used: number
percent: number
free: number
}
// 4. 磁盘信息
export interface DiskInfo {
device: string
mountpoint: string
fstype: string
total: number
used: number
free: number
percent: number
}
// 5. 网络信息
export interface NetworkInfo {
interface: string
bytes_sent: number
bytes_recv: number
packets_sent: number
packets_recv: number
}
// 6. 进程信息
export interface ProcessInfo {
pid: number
name: string
status: string
cpu_percent: number
memory_percent: number
memory_info: {
rss: number
vms: number
}
create_time: string
cmdline: string[]
}
// 7. GPU 信息
export interface GpuInfoItem {
id: number
name: string
load: number
memory_total: number
memory_used: number
memory_util: number
temperature: number
}
export interface GpuInfoResponse {
gpu_available: boolean
gpu_info?: GpuInfoItem[]
message?: string
timestamp: string
}
// 8. 综合监测信息
export interface MonitorData {
system: SystemInfo
cpu: CpuInfo
memory: MemoryInfo
disks: DiskInfo[]
network: NetworkInfo[]
processes: ProcessInfo[]
gpus: Array<{ gpu_available: boolean; message?: string; timestamp: string }>
}
export const systemMonitorApi = {
getSystem: (): Promise<ApiResponse<SystemInfo>> => request.get('/api/system/system/'),
getCpu: (): Promise<ApiResponse<CpuInfo>> => request.get('/api/system/cpu/'),
getMemory: (): Promise<ApiResponse<MemoryInfo>> => request.get('/api/system/memory/'),
getDisks: (): Promise<ApiResponse<DiskInfo[]>> => request.get('/api/system/disks/'),
getNetwork: (): Promise<ApiResponse<NetworkInfo[]>> => request.get('/api/system/network/'),
getProcesses: (): Promise<ApiResponse<ProcessInfo[]>> => request.get('/api/system/processes/'),
getGpu: (): Promise<ApiResponse<GpuInfoResponse>> => request.get('/api/system/gpu/'),
getMonitor: (): Promise<ApiResponse<MonitorData>> => request.get('/api/system/monitor/'),
}

View File

@@ -1,115 +0,0 @@
import { request } from '@/utils/hertz_request'
// 角色接口类型定义
export interface Role {
role_id: number
role_name: string
role_code: string
role_ids?: string
}
// 用户接口类型定义(匹配后端实际数据结构)
export interface User {
user_id: number
username: string
email: string
phone?: string
real_name?: string
avatar?: string
gender: number
birthday?: string
department_id?: number
status: number
last_login_time?: string
last_login_ip?: string
created_at: string
updated_at: string
roles: Role[]
}
// API响应基础结构
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 用户列表数据结构
export interface UserListData {
list: User[]
total: number
page: number
page_size: number
}
// 用户列表响应类型
export type UserListResponse = ApiResponse<UserListData>
// 用户列表查询参数
export interface UserListParams {
page?: number
page_size?: number
search?: string
status?: number
role_ids?: string
}
// 分配角色参数
export interface AssignRolesParams {
user_id: number
role_ids: number[] // 角色ID数组
}
// 用户API
export const userApi = {
// 获取用户列表
getUserList: (params?: UserListParams): Promise<UserListResponse> => {
return request.get('/api/users/', { params })
},
// 获取单个用户
getUser: (id: number): Promise<ApiResponse<User>> => {
return request.get(`/api/users/${id}/`)
},
// 创建用户
createUser: (data: Partial<User>): Promise<ApiResponse<User>> => {
return request.post('/api/users/create/', data)
},
// 更新用户
updateUser: (id: number, data: Partial<User>): Promise<ApiResponse<User>> => {
return request.put(`/api/users/${id}/update/`, data)
},
// 删除用户
deleteUser: (id: number): Promise<ApiResponse<any>> => {
return request.delete(`/api/users/${id}/delete/`)
},
// 批量删除用户
batchDeleteUsers: (ids: number[]): Promise<ApiResponse<any>> => {
return request.post('/api/admin/users/batch-delete/', { user_ids: ids })
},
// 获取当前用户信息
getUserInfo: (): Promise<ApiResponse<User>> => {
return request.get('/api/auth/user/info/')
},
// 更新当前用户信息
updateUserInfo: (data: Partial<User>): Promise<ApiResponse<User>> => {
return request.put('/api/auth/user/info/update/', data)
},
// 分配用户角色
assignRoles: (data: AssignRolesParams): Promise<ApiResponse<any>> => {
return request.post('/api/users/assign-roles/', data)
},
// 获取所有角色列表
getRoleList: (): Promise<ApiResponse<Role[]>> => {
return request.get('/api/roles/')
}
}

View File

@@ -1,428 +0,0 @@
import { request } from '@/utils/hertz_request'
// YOLO检测相关接口类型定义
export interface YoloDetectionRequest {
image: File
model_id?: string
confidence_threshold?: number
nms_threshold?: number
}
export interface DetectionBbox {
x: number
y: number
width: number
height: number
}
export interface YoloDetection {
class_id: number
class_name: string
confidence: number
bbox: DetectionBbox
}
export interface YoloDetectionResponse {
message: string
data?: {
detection_id: number
result_file_url: string
original_file_url: string
object_count: number
detected_categories: string[]
confidence_scores: number[]
avg_confidence: number | null
processing_time: number
model_used: string
confidence_threshold: number
user_id: number
user_name: string
alert_level?: 'low' | 'medium' | 'high'
}
}
export interface YoloModel {
id: string
name: string
version: string
description: string
classes: string[]
is_active: boolean
is_enabled: boolean
model_file: string
model_folder_path: string
model_path: string
weights_folder_path: string
categories: { [key: string]: any }
created_at: string
updated_at: string
}
export interface YoloModelListResponse {
success: boolean
message?: string
data?: {
models: YoloModel[]
total: number
}
}
// YOLO检测API
export const yoloApi = {
// 执行YOLO检测
async detectImage(detectionRequest: YoloDetectionRequest): Promise<YoloDetectionResponse> {
console.log('🔍 构建检测请求:', detectionRequest)
console.log('📁 文件对象详情:', {
name: detectionRequest.image.name,
size: detectionRequest.image.size,
type: detectionRequest.image.type,
lastModified: detectionRequest.image.lastModified
})
const formData = new FormData()
formData.append('file', detectionRequest.image)
if (detectionRequest.model_id) {
formData.append('model_id', detectionRequest.model_id)
}
if (detectionRequest.confidence_threshold) {
formData.append('confidence_threshold', detectionRequest.confidence_threshold.toString())
}
if (detectionRequest.nms_threshold) {
formData.append('nms_threshold', detectionRequest.nms_threshold.toString())
}
// 调试FormData内容
console.log('📤 FormData内容:')
for (const [key, value] of formData.entries()) {
if (value instanceof File) {
console.log(` ${key}: File(${value.name}, ${value.size} bytes, ${value.type})`)
} else {
console.log(` ${key}:`, value)
}
}
return request.post('/api/yolo/detect/', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 获取当前启用的YOLO模型信息
async getCurrentEnabledModel(): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.get('/api/yolo/models/enabled/')
},
// 获取模型详情
async getModelInfo(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.get(`/api/yolo/models/${modelId}`)
},
// 批量检测
async detectBatch(images: File[], modelId?: string): Promise<YoloDetectionResponse[]> {
const promises = images.map(image =>
this.detectImage({
image,
model_id: modelId,
confidence_threshold: 0.5,
nms_threshold: 0.4
})
)
return Promise.all(promises)
},
// 获取模型列表
async getModels(): Promise<{ success: boolean; data?: YoloModel[]; message?: string }> {
return request.get('/api/yolo/models/')
},
// 上传模型
async uploadModel(formData: FormData): Promise<{ success: boolean; message?: string }> {
// 使用专门的upload方法它会自动处理Content-Type
return request.upload('/api/yolo/upload/', formData)
},
// 更新模型信息
async updateModel(modelId: string, data: { name: string; version: string }): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.put(`/api/yolo/models/${modelId}/update/`, data)
},
// 删除模型
async deleteModel(modelId: string): Promise<{ success: boolean; message?: string }> {
return request.delete(`/api/yolo/models/${modelId}/delete/`)
},
// 启用模型
async enableModel(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.post(`/api/yolo/models/${modelId}/enable/`)
},
// 获取模型详情
async getModelDetail(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.get(`/api/yolo/models/${modelId}/`)
},
// 获取检测历史记录列表
async getDetectionHistory(params?: {
page?: number
page_size?: number
search?: string
start_date?: string
end_date?: string
model_id?: string
}): Promise<{ success: boolean; data?: DetectionHistoryRecord[]; message?: string }> {
return request.get('/api/yolo/detections/', { params })
},
// 获取检测记录详情
async getDetectionDetail(recordId: string): Promise<{ success: boolean; data?: DetectionHistoryRecord; message?: string }> {
return request.get(`/api/detections/${recordId}/`)
},
// 删除检测记录
async deleteDetection(recordId: string): Promise<{ success: boolean; message?: string }> {
return request.delete(`/api/yolo/detections/${recordId}/delete/`)
},
// 批量删除检测记录
async batchDeleteDetections(ids: number[]): Promise<{ success: boolean; message?: string }> {
return request.post('/api/yolo/detections/batch-delete/', { ids })
},
// 获取检测统计
async getDetectionStats(): Promise<{ success: boolean; data?: any; message?: string }> {
return request.get('/api/yolo/stats/')
},
// 警告等级管理相关接口
// 获取警告等级列表
async getAlertLevels(): Promise<{ success: boolean; data?: AlertLevel[]; message?: string }> {
return request.get('/api/yolo/categories/')
},
// 获取警告等级详情
async getAlertLevelDetail(levelId: string): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
return request.get(`/api/yolo/categories/${levelId}/`)
},
// 更新警告等级
async updateAlertLevel(levelId: string, data: { alert_level?: 'low' | 'medium' | 'high'; alias?: string }): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
return request.put(`/api/yolo/categories/${levelId}/update/`, data)
},
// 切换警告等级状态
async toggleAlertLevelStatus(levelId: string): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
return request.post(`/api/yolo/categories/${levelId}/toggle-status/`)
},
// 获取活跃的警告等级列表
async getActiveAlertLevels(): Promise<{ success: boolean; data?: AlertLevel[]; message?: string }> {
return request.get('/api/yolo/categories/active/')
},
// 上传并转换PT模型为ONNX格式
async uploadAndConvertToOnnx(formData: FormData): Promise<{
success: boolean
message?: string
data?: {
onnx_path?: string
onnx_url?: string
download_url?: string
onnx_relative_path?: string
file_name?: string
labels_download_url?: string
labels_relative_path?: string
classes?: string[]
}
}> {
// 适配后端 @views.py 中的 upload_pt_convert_onnx 实现
// 统一走 /api/upload_pt_convert_onnx
// 按你的后端接口:/yolo/onnx/upload/
// 注意带上结尾斜杠,避免 404
return request.upload('/api/yolo/onnx/upload/', formData)
}
}
// 警告等级管理相关接口
export interface AlertLevel {
id: number
model: number
model_name: string
name: string
alias: string
display_name: string
category_id: number
alert_level: 'low' | 'medium' | 'high'
alert_level_display: string
is_active: boolean
// 前端编辑状态字段
editingAlias?: boolean
tempAlias?: string
}
// 用户检测历史相关接口
export interface DetectionHistoryRecord {
id: number
user_id: number
original_filename: string
result_filename: string
original_file: string
result_file: string
detection_type: 'image' | 'video'
object_count: number
detected_categories: string[]
confidence_scores: number[]
avg_confidence: number | null
processing_time: number
model_name: string
model_info: any
created_at: string
confidence_threshold?: number // 置信度阈值(原始设置值)
// 为了兼容前端显示,添加计算字段
filename?: string
image_url?: string
detections?: YoloDetection[]
}
export interface DetectionHistoryParams {
page?: number
page_size?: number
search?: string
class_filter?: string
start_date?: string
end_date?: string
model_id?: string
}
export interface DetectionHistoryResponse {
success?: boolean
message?: string
data?: {
records: DetectionHistoryRecord[]
total: number
page: number
page_size: number
} | DetectionHistoryRecord[]
// 支持直接返回数组的情况
results?: DetectionHistoryRecord[]
count?: number
// 支持Django REST framework的分页格式
next?: string
previous?: string
}
// 用户检测历史API
export const detectionHistoryApi = {
// 获取用户检测历史
async getUserDetectionHistory(userId: number, params: DetectionHistoryParams = {}): Promise<DetectionHistoryResponse> {
return request.get('/api/yolo/detections/', {
params: {
user_id: userId,
...params
}
})
},
// 获取检测记录详情
async getDetectionRecordDetail(recordId: number): Promise<{
success?: boolean
code?: number
message?: string
data?: DetectionHistoryRecord
}> {
return request.get(`/api/yolo/detections/${recordId}/`)
},
// 删除检测记录
async deleteDetectionRecord(userId: number, recordId: string): Promise<{ success: boolean; message?: string }> {
return request.delete(`/api/yolo/detections/${recordId}/delete/`)
},
// 批量删除检测记录
async batchDeleteDetectionRecords(userId: number, recordIds: string[]): Promise<{ success: boolean; message?: string }> {
return request.post('/api/yolo/detections/batch-delete/', { ids: recordIds })
},
// 导出检测历史
async exportDetectionHistory(userId: number, params: DetectionHistoryParams = {}): Promise<Blob> {
const response = await request.get('/api/yolo/detections/export/', {
params: {
user_id: userId,
...params
},
responseType: 'blob'
})
return response
},
// 获取检测统计信息
async getDetectionStats(userId: number): Promise<{
success: boolean
data?: {
total_detections: number
total_images: number
class_counts: Record<string, number>
recent_activity: Array<{
date: string
count: number
}>
}
message?: string
}> {
return request.get('/api/yolo/detections/stats/', {
params: { user_id: userId }
})
}
}
// 告警相关接口类型定义
export interface AlertRecord {
id: number
detection_record: number
detection_info: {
id: number
detection_type: string
original_filename: string
result_filename: string
object_count: number
avg_confidence: number
}
user: number
user_name: string
alert_level: string
alert_level_display: string
alert_category: string
category: number
category_info: {
id: number
name: string
alert_level: string
alert_level_display: string
}
status: string
created_at: string
deleted_at: string | null
}
// 告警管理API
export const alertApi = {
// 获取所有告警记录
async getAllAlerts(): Promise<{ success: boolean; data?: AlertRecord[]; message?: string }> {
return request.get('/api/yolo/alerts/')
},
// 获取当前用户的告警记录
async getUserAlerts(userId: string): Promise<{ success: boolean; data?: AlertRecord[]; message?: string }> {
return request.get(`/api/yolo/users/${userId}/alerts/`)
},
// 处理告警(更新状态)
async updateAlertStatus(alertId: string, status: string): Promise<{ success: boolean; data?: AlertRecord; message?: string }> {
return request.put(`/api/yolo/alerts/${alertId}/update-status/`, { status })
}
}
// 默认导出
export default yoloApi

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,55 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script setup lang="ts">
defineProps<{ msg: string }>()
</script>
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@@ -1,159 +0,0 @@
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
reset: 'Reset',
loading: 'Loading...',
noData: 'No Data',
success: 'Success',
error: 'Error',
warning: 'Warning',
info: 'Info',
},
nav: {
home: 'Home',
dashboard: 'Dashboard',
user: 'User Management',
role: 'Role Management',
menu: 'Menu Management',
settings: 'System Settings',
profile: 'Profile',
logout: 'Logout',
},
login: {
title: 'Login',
username: 'Username',
password: 'Password',
login: 'Login',
forgotPassword: 'Forgot Password?',
rememberMe: 'Remember Me',
},
success: {
// General success messages
operationSuccess: 'Operation Successful',
saveSuccess: 'Save Successful',
deleteSuccess: 'Delete Successful',
updateSuccess: 'Update Successful',
// Login and registration related success messages
loginSuccess: 'Login Successful',
registerSuccess: 'Registration Successful! Please Login',
logoutSuccess: 'Logout Successful',
emailCodeSent: 'Verification Code Sent to Your Email',
// User management related success messages
userCreated: 'User Created Successfully',
userUpdated: 'User Information Updated Successfully',
userDeleted: 'User Deleted Successfully',
roleAssigned: 'Role Assigned Successfully',
// Other operation success messages
uploadSuccess: 'File Upload Successful',
downloadSuccess: 'File Download Successful',
copySuccess: 'Copy Successful',
},
error: {
// General errors
// 404: 'Page Not Found',
403: 'Access Denied, Please Contact Administrator',
500: 'Internal Server Error, Please Try Again Later',
networkError: 'Network Connection Failed, Please Check Network Settings',
timeout: 'Request Timeout, Please Try Again Later',
// Login related errors
loginFailed: 'Login Failed, Please Check Username and Password',
usernameRequired: 'Please Enter Username',
passwordRequired: 'Please Enter Password',
captchaRequired: 'Please Enter Captcha',
captchaError: 'Captcha Error, Please Re-enter (Case Sensitive)',
captchaExpired: 'Captcha Expired, Please Refresh and Re-enter',
accountLocked: 'Account Locked, Please Contact Administrator',
accountDisabled: 'Account Disabled, Please Contact Administrator',
passwordExpired: 'Password Expired, Please Change Password',
loginAttemptsExceeded: 'Too Many Login Attempts, Account Temporarily Locked',
// Registration related errors
registerFailed: 'Registration Failed, Please Check Input Information',
usernameExists: 'Username Already Exists, Please Choose Another',
emailExists: 'Email Already Registered, Please Use Another Email',
phoneExists: 'Phone Number Already Registered, Please Use Another',
emailFormatError: 'Invalid Email Format, Please Enter Valid Email',
phoneFormatError: 'Invalid Phone Format, Please Enter 11-digit Phone Number',
passwordTooWeak: 'Password Too Weak, Please Include Uppercase, Lowercase, Numbers and Special Characters',
passwordMismatch: 'Passwords Do Not Match',
emailCodeError: 'Email Verification Code Error or Expired',
emailCodeRequired: 'Please Enter Email Verification Code',
emailCodeLength: 'Verification Code Must Be 6 Digits',
emailRequired: 'Please Enter Email',
usernameLength: 'Username Length Must Be 3-20 Characters',
passwordLength: 'Password Length Must Be 6-20 Characters',
confirmPasswordRequired: 'Please Confirm Password',
phoneRequired: 'Please Enter Phone Number',
realNameRequired: 'Please Enter Real Name',
realNameLength: 'Name Length Must Be 2-10 Characters',
// Permission related errors
accessDenied: 'Access Denied, You Do Not Have Permission to Perform This Action',
roleNotFound: 'Role Not Found or Deleted',
permissionDenied: 'Permission Denied, Cannot Perform This Action',
tokenExpired: 'Login Expired, Please Login Again',
tokenInvalid: 'Invalid Login Status, Please Login Again',
// User management related errors
userNotFound: 'User Not Found or Deleted',
userCreateFailed: 'Failed to Create User, Please Check Input Information',
userUpdateFailed: 'Failed to Update User Information',
userDeleteFailed: 'Failed to Delete User, User May Be In Use',
cannotDeleteSelf: 'Cannot Delete Your Own Account',
cannotDeleteAdmin: 'Cannot Delete Administrator Account',
// Department management related errors
departmentNotFound: 'Department Not Found or Deleted',
departmentNameExists: 'Department Name Already Exists',
departmentHasUsers: 'Department Has Users, Cannot Delete',
departmentCreateFailed: 'Failed to Create Department',
departmentUpdateFailed: 'Failed to Update Department Information',
departmentDeleteFailed: 'Failed to Delete Department',
// Role management related errors
roleNameExists: 'Role Name Already Exists',
roleCreateFailed: 'Failed to Create Role',
roleUpdateFailed: 'Failed to Update Role Information',
roleDeleteFailed: 'Failed to Delete Role',
roleInUse: 'Role In Use, Cannot Delete',
// File upload related errors
fileUploadFailed: 'File Upload Failed',
fileSizeExceeded: 'File Size Exceeded Limit',
fileTypeNotSupported: 'File Type Not Supported',
fileRequired: 'Please Select File to Upload',
// Data validation related errors
invalidInput: 'Invalid Input Data Format',
requiredFieldMissing: 'Required Field Cannot Be Empty',
fieldTooLong: 'Input Content Exceeds Length Limit',
fieldTooShort: 'Input Content Length Insufficient',
invalidDate: 'Invalid Date Format',
invalidNumber: 'Invalid Number Format',
// Operation related errors
operationFailed: 'Operation Failed, Please Try Again Later',
saveSuccess: 'Save Successful',
saveFailed: 'Save Failed, Please Check Input Information',
deleteSuccess: 'Delete Successful',
deleteFailed: 'Delete Failed, Please Try Again Later',
updateSuccess: 'Update Successful',
updateFailed: 'Update Failed, Please Check Input Information',
// System related errors
systemMaintenance: 'System Under Maintenance, Please Visit Later',
serviceUnavailable: 'Service Temporarily Unavailable, Please Try Again Later',
databaseError: 'Database Connection Error, Please Contact Technical Support',
configError: 'System Configuration Error, Please Contact Administrator',
},
}

View File

@@ -1,18 +0,0 @@
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN'
import enUS from './en-US'
const messages = {
'zh-CN': zhCN,
'en-US': enUS,
}
export const i18n = createI18n({
locale: 'zh-CN',
fallbackLocale: 'en-US',
messages,
legacy: false,
globalInjection: true,
})
export default i18n

View File

@@ -1,172 +0,0 @@
export default {
common: {
confirm: '确定',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '添 加',
search: '搜索',
reset: '重置',
loading: '加载中...',
noData: '暂无数据',
success: '成功',
error: '错误',
warning: '警告',
info: '提示',
},
nav: {
home: '首页',
dashboard: '仪表板',
user: '用户管理',
role: '角色管理',
menu: '菜单管理',
settings: '系统设置',
profile: '个人资料',
logout: '退出登录',
},
login: {
title: '登录',
username: '用户名',
password: '密码',
login: '登录',
forgotPassword: '忘记密码?',
rememberMe: '记住我',
},
register: {
title: '注册',
username: '用户名',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
register: '注册',
agreement: '我已阅读并同意',
userAgreement: '用户协议',
privacyPolicy: '隐私政策',
hasAccount: '已有账号?',
goToLogin: '立即登录',
},
success: {
// 通用成功提示
operationSuccess: '操作成功',
saveSuccess: '保存成功',
deleteSuccess: '删除成功',
updateSuccess: '更新成功',
// 登录注册相关成功提示
loginSuccess: '登录成功',
registerSuccess: '注册成功!请前往登录',
logoutSuccess: '退出登录成功',
emailCodeSent: '验证码已发送到您的邮箱',
// 用户管理相关成功提示
userCreated: '用户创建成功',
userUpdated: '用户信息更新成功',
userDeleted: '用户删除成功',
roleAssigned: '角色分配成功',
// 其他操作成功提示
uploadSuccess: '文件上传成功',
downloadSuccess: '文件下载成功',
copySuccess: '复制成功',
},
error: {
// 通用错误
// 404: '页面未找到',
403: '权限不足,请联系管理员',
500: '服务器内部错误,请稍后重试',
networkError: '网络连接失败,请检查网络设置',
timeout: '请求超时,请稍后重试',
// 登录相关错误
loginFailed: '登录失败,请检查用户名和密码',
usernameRequired: '请输入用户名',
passwordRequired: '请输入密码',
captchaRequired: '请输入验证码',
captchaError: '验证码错误,请重新输入(区分大小写)',
captchaExpired: '验证码已过期,请刷新后重新输入',
accountLocked: '账户已被锁定,请联系管理员',
accountDisabled: '账户已被禁用,请联系管理员',
passwordExpired: '密码已过期,请修改密码',
loginAttemptsExceeded: '登录尝试次数过多,账户已被临时锁定',
// 注册相关错误
registerFailed: '注册失败,请检查输入信息',
usernameExists: '用户名已存在,请选择其他用户名',
emailExists: '邮箱已被注册,请使用其他邮箱',
phoneExists: '手机号已被注册,请使用其他手机号',
emailFormatError: '邮箱格式不正确,请输入有效的邮箱地址',
phoneFormatError: '手机号格式不正确请输入11位手机号',
passwordTooWeak: '密码强度不足,请包含大小写字母、数字和特殊字符',
passwordMismatch: '两次输入的密码不一致',
emailCodeError: '邮箱验证码错误或已过期',
emailCodeRequired: '请输入邮箱验证码',
emailCodeLength: '验证码长度为6位',
emailRequired: '请输入邮箱',
usernameLength: '用户名长度为3-20个字符',
passwordLength: '密码长度为6-20个字符',
confirmPasswordRequired: '请确认密码',
phoneRequired: '请输入手机号',
realNameRequired: '请输入真实姓名',
realNameLength: '姓名长度为2-10个字符',
// 权限相关错误
accessDenied: '访问被拒绝,您没有执行此操作的权限',
roleNotFound: '角色不存在或已被删除',
permissionDenied: '权限不足,无法执行此操作',
tokenExpired: '登录已过期,请重新登录',
tokenInvalid: '登录状态无效,请重新登录',
// 用户管理相关错误
userNotFound: '用户不存在或已被删除',
userCreateFailed: '创建用户失败,请检查输入信息',
userUpdateFailed: '更新用户信息失败',
userDeleteFailed: '删除用户失败,该用户可能正在使用中',
cannotDeleteSelf: '不能删除自己的账户',
cannotDeleteAdmin: '不能删除管理员账户',
// 部门管理相关错误
departmentNotFound: '部门不存在或已被删除',
departmentNameExists: '部门名称已存在',
departmentHasUsers: '部门下还有用户,无法删除',
departmentCreateFailed: '创建部门失败',
departmentUpdateFailed: '更新部门信息失败',
departmentDeleteFailed: '删除部门失败',
// 角色管理相关错误
roleNameExists: '角色名称已存在',
roleCreateFailed: '创建角色失败',
roleUpdateFailed: '更新角色信息失败',
roleDeleteFailed: '删除角色失败',
roleInUse: '角色正在使用中,无法删除',
// 文件上传相关错误
fileUploadFailed: '文件上传失败',
fileSizeExceeded: '文件大小超出限制',
fileTypeNotSupported: '不支持的文件类型',
fileRequired: '请选择要上传的文件',
// 数据验证相关错误
invalidInput: '输入数据格式不正确',
requiredFieldMissing: '必填字段不能为空',
fieldTooLong: '输入内容超出长度限制',
fieldTooShort: '输入内容长度不足',
invalidDate: '日期格式不正确',
invalidNumber: '数字格式不正确',
// 操作相关错误
operationFailed: '操作失败,请稍后重试',
saveSuccess: '保存成功',
saveFailed: '保存失败,请检查输入信息',
deleteSuccess: '删除成功',
deleteFailed: '删除失败,请稍后重试',
updateSuccess: '更新成功',
updateFailed: '更新失败,请检查输入信息',
// 系统相关错误
systemMaintenance: '系统正在维护中,请稍后访问',
serviceUnavailable: '服务暂时不可用,请稍后重试',
databaseError: '数据库连接错误,请联系技术支持',
configError: '系统配置错误,请联系管理员',
},
}

View File

@@ -1,47 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from './locales'
import { checkEnvironmentVariables, validateEnvironment } from './utils/hertz_env'
import './styles/index.scss'
// 导入Ant Design Vue
import 'ant-design-vue/dist/antd.css'
// 开发环境检查
if (import.meta.env.DEV) {
checkEnvironmentVariables()
validateEnvironment()
}
// 创建Vue应用实例
const app = createApp(App)
// 使用Pinia状态管理
const pinia = createPinia()
app.use(pinia)
// 使用路由
app.use(router)
// 使用国际化
app.use(i18n)
// 初始化应用设置
import { useAppStore } from './stores/hertz_app'
const appStore = useAppStore()
appStore.initAppSettings()
// 检查用户认证状态
import { useUserStore } from './stores/hertz_user'
const userStore = useUserStore()
userStore.checkAuth()
// 初始化主题(必须在挂载前加载)
import { useThemeStore } from './stores/hertz_theme'
const themeStore = useThemeStore()
themeStore.loadTheme()
// 挂载应用
app.mount('#app')

View File

@@ -1,69 +0,0 @@
import { request } from '@/utils/hertz_request'
// 通用响应类型
export interface ApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 会话与消息类型
export interface AIChatItem {
id: number
title: string
created_at: string
updated_at: string
latest_message?: string
}
export interface AIChatDetail {
id: number
title: string
created_at: string
updated_at: string
}
export interface AIChatMessage {
id: number
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface ChatListData {
total: number
page: number
page_size: number
chats: AIChatItem[]
}
export interface ChatDetailData {
chat: AIChatDetail
messages: AIChatMessage[]
}
export interface SendMessageData {
user_message: AIChatMessage
ai_message: AIChatMessage
}
export const aiApi = {
listChats: (params?: { query?: string; page?: number; page_size?: number }): Promise<ApiResponse<ChatListData>> =>
request.get('/api/ai/chats/', { params, showError: false }),
createChat: (body?: { title?: string }): Promise<ApiResponse<AIChatDetail>> =>
request.post('/api/ai/chats/create/', body || { title: '新对话' }),
getChatDetail: (chatId: number): Promise<ApiResponse<ChatDetailData>> =>
request.get(`/api/ai/chats/${chatId}/`),
updateChat: (chatId: number, body: { title: string }): Promise<ApiResponse<null>> =>
request.put(`/api/ai/chats/${chatId}/update/`, body),
deleteChats: (chatIds: number[]): Promise<ApiResponse<null>> =>
request.post('/api/ai/chats/delete/', { chat_ids: chatIds }),
sendMessage: (chatId: number, body: { content: string }): Promise<ApiResponse<SendMessageData>> =>
request.post(`/api/ai/chats/${chatId}/send/`, body),
}

View File

@@ -1,231 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
import { defineAsyncComponent } from 'vue'
// 统一的菜单项配置接口
export interface UserMenuConfig {
key: string
label: string
icon?: string
path: string
component: string // 组件路径,相对于 @/views/user_pages/
children?: UserMenuConfig[]
disabled?: boolean
meta?: {
title?: string
requiresAuth?: boolean
roles?: string[]
[key: string]: any
}
}
// 菜单项接口定义(用于前端显示)
export interface MenuItem {
key: string
label: string
icon?: string
path?: string
children?: MenuItem[]
disabled?: boolean
}
// 统一配置 - 同时用于菜单和路由
export const userMenuConfigs: UserMenuConfig[] = [
{
key: 'dashboard',
label: '首页',
icon: 'DashboardOutlined',
path: '/dashboard',
component: 'index.vue',
meta: { title: '用户首页', requiresAuth: true }
},
{
key: 'profile',
label: '个人信息',
icon: 'UserOutlined',
path: '/user/profile',
component: 'Profile.vue',
meta: { title: '个人信息', requiresAuth: true }
},
{
key: 'documents',
label: '文档管理',
icon: 'FileTextOutlined',
path: '/user/documents',
component: 'Documents.vue',
meta: { title: '文档管理', requiresAuth: true }
},
{
key: 'messages',
label: '消息中心',
icon: 'MessageOutlined',
path: '/user/messages',
component: 'Messages.vue',
meta: { title: '消息中心', requiresAuth: true }
},
{
key: 'system-monitor',
label: '系统监控',
icon: 'DashboardOutlined',
path: '/user/system-monitor',
component: 'SystemMonitor.vue',
meta: { title: '系统监控', requiresAuth: true }
},
{
key: 'ai-chat',
label: 'AI助手',
icon: 'MessageOutlined',
path: '/user/ai-chat',
component: 'AiChat.vue',
meta: { title: 'AI助手', requiresAuth: true }
},
]
// 显式组件映射 - 避免Vite动态导入限制
const explicitComponentMap: Record<string, any> = {
'index.vue': defineAsyncComponent(() => import('@/views/user_pages/index.vue')),
'Profile.vue': defineAsyncComponent(() => import('@/views/user_pages/Profile.vue')),
'Documents.vue': defineAsyncComponent(() => import('@/views/user_pages/Documents.vue')),
'Messages.vue': defineAsyncComponent(() => import('@/views/user_pages/Messages.vue')),
'SystemMonitor.vue': defineAsyncComponent(() => import('@/views/user_pages/SystemMonitor.vue')),
'AiChat.vue': defineAsyncComponent(() => import('@/views/user_pages/AiChat.vue')),
}
// 自动生成菜单项(用于前端显示)
export const userMenuItems: MenuItem[] = userMenuConfigs.map(config => ({
key: config.key,
label: config.label,
icon: config.icon,
path: config.path,
disabled: config.disabled,
children: config.children?.map(child => ({
key: child.key,
label: child.label,
icon: child.icon,
path: child.path,
disabled: child.disabled
}))
}))
// 组件映射表 - 用于解决Vite动态导入限制
const componentMap: Record<string, () => Promise<any>> = {
'index.vue': () => import('@/views/user_pages/index.vue'),
'Profile.vue': () => import('@/views/user_pages/Profile.vue'),
'Documents.vue': () => import('@/views/user_pages/Documents.vue'),
'Messages.vue': () => import('@/views/user_pages/Messages.vue'),
'SystemMonitor.vue': () => import('@/views/user_pages/SystemMonitor.vue'),
'AiChat.vue': () => import('@/views/user_pages/AiChat.vue'),
}
// 自动生成路由配置
export const userRoutes: RouteRecordRaw[] = userMenuConfigs.map(config => {
const route: RouteRecordRaw = {
path: config.path,
name: `User${config.key.charAt(0).toUpperCase() + config.key.slice(1)}`,
component: componentMap[config.component] || (() => import('@/views/NotFound.vue')),
meta: {
title: config.meta?.title || config.label,
requiresAuth: config.meta?.requiresAuth ?? true,
...config.meta
}
}
if (config.children && config.children.length > 0) {
route.children = config.children.map(child => ({
path: child.path,
name: `User${child.key.charAt(0).toUpperCase() + child.key.slice(1)}`,
component: componentMap[child.component] || (() => import('@/views/NotFound.vue')),
meta: {
title: child.meta?.title || child.label,
requiresAuth: child.meta?.requiresAuth ?? true,
...child.meta
}
}))
}
return route
})
// 根据菜单项生成路由路径
export function getMenuPath(menuKey: string): string {
const findPath = (items: MenuItem[], key: string): string | null => {
for (const item of items) {
if (item.key === key && item.path) return item.path
if (item.children) {
const childPath = findPath(item.children, key)
if (childPath) return childPath
}
}
return null
}
return findPath(userMenuItems, menuKey) || '/dashboard'
}
// 获取菜单的面包屑路径
export function getMenuBreadcrumb(menuKey: string): string[] {
const findBreadcrumb = (items: MenuItem[], key: string, path: string[] = []): string[] | null => {
for (const item of items) {
const currentPath = [...path, item.label]
if (item.key === menuKey) return currentPath
if (item.children) {
const childPath = findBreadcrumb(item.children, key, currentPath)
if (childPath) return childPath
}
}
return null
}
return findBreadcrumb(userMenuItems, menuKey) || ['仪表盘']
}
// 自动生成组件映射(基于配置和显式映射)
export const generateComponentMap = () => {
const map: Record<string, any> = {}
const processConfigs = (configs: UserMenuConfig[]) => {
configs.forEach(config => {
if (explicitComponentMap[config.component]) {
map[config.key] = explicitComponentMap[config.component]
} else {
map[config.key] = defineAsyncComponent(() => import('@/views/NotFound.vue'))
}
if (config.children) processConfigs(config.children)
})
}
processConfigs(userMenuConfigs)
return map
}
// 导出自动生成的组件映射
export const userComponentMap = generateComponentMap()
// 根据用户权限过滤菜单项
export const getFilteredUserMenuItems = (userRoles: string[], userPermissions: string[]): MenuItem[] => {
return userMenuConfigs
.filter(config => {
if (!config.meta?.roles || config.meta.roles.length === 0) return true
return config.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
})
.map(config => ({
key: config.key,
label: config.label,
icon: config.icon,
path: config.path,
disabled: config.disabled,
children: config.children?.filter(child => {
if (!child.meta?.roles || child.meta.roles.length === 0) return true
return child.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
}).map(child => ({
key: child.key,
label: child.label,
icon: child.icon,
path: child.path,
disabled: child.disabled
}))
}))
}
// 检查用户是否有访问特定菜单的权限
export const hasUserMenuPermission = (menuKey: string, userRoles: string[]): boolean => {
const menuConfig = userMenuConfigs.find(config => config.key === menuKey)
if (!menuConfig) return false
if (!menuConfig.meta?.roles || menuConfig.meta.roles.length === 0) return true
return menuConfig.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
}

View File

@@ -1,261 +0,0 @@
<template>
<div class="ai-chat-page">
<a-page-header title="AI助手" sub-title=" AI 进行智能对话">
<template #extra>
<a-space>
<a-input-search v-model:value="query" placeholder="搜索会话标题" style="width: 240px" @search="fetchChats" />
<a-button @click="fetchChats" :loading="loadingChats">
<ReloadOutlined />
刷新
</a-button>
<a-button type="primary" @click="createChat" :loading="creating">
<PlusOutlined />
新建对话
</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="16">
<!-- 左侧会话列表 -->
<a-col :xs="24" :md="8" :lg="6">
<a-card title="我的对话" bordered>
<a-list
:data-source="chatList"
item-layout="horizontal"
:loading="loadingChats"
:pagination="{ pageSize: pageSize, total: total, current: page, onChange: onPageChange }"
>
<template #renderItem="{ item }">
<a-list-item :class="{ active: item.id === currentChatId }" @click="selectChat(item.id)">
<a-list-item-meta>
<template #title>
<div class="chat-title-row">
<span class="chat-title">{{ item.title }}</span>
<a-space>
<a-button size="small" type="text" @click.stop="openRename(item)">
<EditOutlined />
</a-button>
<a-popconfirm title="确认删除该对话?" @confirm="deleteChat(item.id)">
<a-button size="small" danger type="text" @click.stop>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</a-space>
</div>
</template>
<template #description>
<div class="chat-desc">{{ item.latest_message || '暂无消息' }}</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<!-- 右侧消息区 -->
<a-col :xs="24" :md="16" :lg="18">
<a-card :title="currentChat?.title || '请选择或新建对话'" bordered class="chat-card">
<div class="messages" ref="messagesEl">
<template v-if="messages.length">
<div v-for="m in messages" :key="m.id" :class="['msg', m.role]">
<div class="bubble">
<div class="content" v-html="renderContent(m.content)"></div>
<div class="time">{{ formatTime(m.created_at) }}</div>
</div>
</div>
</template>
<a-empty v-else description="暂无消息" />
</div>
<template #footer>
<div class="composer">
<a-textarea v-model:value="input" :rows="3" placeholder="输入你的问题..." :disabled="!currentChatId" />
<a-space style="margin-top: 8px;">
<a-button type="primary" :disabled="!canSend" :loading="sending" @click="send">
<SendOutlined />
发送
</a-button>
</a-space>
</div>
</template>
</a-card>
</a-col>
</a-row>
<!-- 重命名对话 -->
<a-modal v-model:open="renameOpen" title="重命名对话" @ok="doRename" :confirm-loading="renaming">
<a-input v-model:value="renameTitle" placeholder="请输入新标题" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, SendOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { aiApi, type AIChatItem, type AIChatDetail, type AIChatMessage } from '@/api/ai'
// 会话列表状态
const chatList = ref<AIChatItem[]>([])
const query = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loadingChats = ref(false)
const creating = ref(false)
// 当前会话与消息
const currentChatId = ref<number | null>(null)
const currentChat = ref<AIChatDetail | null>(null)
const messages = ref<AIChatMessage[]>([])
const loadingMessages = ref(false)
const sending = ref(false)
// 重命名
const renameOpen = ref(false)
const renameTitle = ref('')
const renaming = ref(false)
let renameTargetId: number | null = null
const messagesEl = ref<HTMLElement | null>(null)
const canSend = computed(() => !!currentChatId.value && !!input.value.trim())
const input = ref('')
const fetchChats = async () => {
loadingChats.value = true
try {
const res = await aiApi.listChats({ query: query.value || undefined, page: page.value, page_size: pageSize.value })
if (res.success) {
chatList.value = res.data.chats || []
total.value = res.data.total || 0
// 保持选择
if (!currentChatId.value && chatList.value.length) selectChat(chatList.value[0].id)
} else {
message.error(res.message || '获取对话列表失败')
}
} catch (e: any) {
if (e?.response?.status === 403) {
message.warning('暂无权限访问AI助手请联系管理员开通权限')
} else {
message.error(e?.message || '网络错误')
}
} finally {
loadingChats.value = false
}
}
const onPageChange = (p: number) => { page.value = p; fetchChats() }
const selectChat = async (id: number) => {
if (currentChatId.value === id && messages.value.length) return
currentChatId.value = id
loadingMessages.value = true
try {
const res = await aiApi.getChatDetail(id)
if (res.success) {
currentChat.value = res.data.chat
messages.value = res.data.messages || []
await nextTick(); scrollToBottom()
} else {
message.error(res.message || '获取会话详情失败')
}
} catch (e: any) {
message.error(e?.message || '网络错误')
} finally {
loadingMessages.value = false
}
}
const createChat = async () => {
creating.value = true
try {
const res = await aiApi.createChat({ title: '新对话' })
if (res.success) {
message.success('创建成功')
await fetchChats()
selectChat(res.data.id)
} else {
message.error(res.message || '创建失败')
}
} catch (e: any) {
message.error(e?.message || '网络错误')
} finally {
creating.value = false
}
}
const openRename = (item: AIChatItem) => {
renameTargetId = item.id
renameTitle.value = item.title
renameOpen.value = true
}
const doRename = async () => {
if (!renameTargetId || !renameTitle.value.trim()) { message.warning('标题不能为空'); return }
renaming.value = true
try {
const res = await aiApi.updateChat(renameTargetId, { title: renameTitle.value.trim() })
if (res.success) { message.success('重命名成功'); renameOpen.value = false; await fetchChats() }
else { message.error(res.message || '重命名失败') }
} catch (e: any) { message.error(e?.message || '网络错误') }
finally { renaming.value = false }
}
const deleteChat = async (id: number) => {
try {
const res = await aiApi.deleteChats([id])
if (res.success) {
message.success('删除成功')
await fetchChats()
if (currentChatId.value === id) { currentChatId.value = null; currentChat.value = null; messages.value = [] }
} else {
message.error(res.message || '删除失败')
}
} catch (e: any) { message.error(e?.message || '网络错误') }
}
const send = async () => {
if (!canSend.value || !currentChatId.value) return
const content = input.value.trim()
sending.value = true
try {
const res = await aiApi.sendMessage(currentChatId.value, { content })
if (res.success) {
messages.value.push(res.data.user_message, res.data.ai_message)
input.value = ''
await nextTick(); scrollToBottom(); fetchChats() // 更新列表预览
} else {
message.error(res.message || '发送失败')
}
} catch (e: any) { message.error(e?.message || '网络错误') }
finally { sending.value = false }
}
const formatTime = (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm:ss')
const renderContent = (c: string) => c.replace(/\n/g, '<br/>')
const scrollToBottom = () => { const el = messagesEl.value; if (el) el.scrollTop = el.scrollHeight }
onMounted(() => { fetchChats() })
</script>
<style scoped lang="scss">
.ai-chat-page { padding: 16px; }
.chat-title-row { display: flex; justify-content: space-between; align-items: center; }
.chat-desc { color: #666; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages { min-height: 420px; max-height: 60vh; overflow-y: auto; padding: 8px; background: #fafafa; border-radius: 8px; }
.msg { display: flex; margin-bottom: 12px; }
.msg .bubble { max-width: 80%; padding: 10px 12px; border-radius: 8px; position: relative; }
.msg .time { margin-top: 6px; font-size: 12px; color: #999; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble { background: #e6f7ff; }
.msg.assistant { justify-content: flex-start; }
.msg.assistant .bubble { background: #f6ffed; }
.composer { margin-top: 8px; }
.chat-card :deep(.ant-card-head) { background: #fff; }
.active { background: #f0f7ff; }
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,424 +0,0 @@
import type { RouteRecordRaw } from "vue-router";
// 角色权限枚举
export enum UserRole {
ADMIN = 'admin',
SYSTEM_ADMIN = 'system_admin',
NORMAL_USER = 'normal_user',
SUPER_ADMIN = 'super_admin'
}
// 统一菜单配置接口 - 只需要在这里配置一次
export interface AdminMenuItem {
key: string; // 菜单唯一标识
title: string; // 菜单标题
icon?: string; // 菜单图标
path: string; // 路由路径
component: string; // 组件路径(相对于@/views/admin_page/
isDefault?: boolean; // 是否为默认路由(首页)
roles?: UserRole[]; // 允许访问的角色,不设置则使用默认管理员角色
permission?: string; // 所需权限标识符
children?: AdminMenuItem[]; // 子菜单
}
// 🎯 统一配置中心 - 只需要在这里修改菜单配置
export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
{
key: "dashboard",
title: "仪表盘",
icon: "DashboardOutlined",
path: "/admin",
component: "Dashboard.vue",
isDefault: true, // 标记为默认首页
},
{
key: "user-management",
title: "用户管理",
icon: "UserOutlined",
path: "/admin/user-management",
component: "UserManagement.vue",
permission: "system:user:list", // 需要用户列表权限
},
{
key: "department-management",
title: "部门管理",
icon: "SettingOutlined",
path: "/admin/department-management",
component: "DepartmentManagement.vue",
permission: "system:dept:list", // 需要部门列表权限
},
{
key: "menu-management",
title: "菜单管理",
icon: "SettingOutlined",
path: "/admin/menu-management",
component: "MenuManagement.vue",
permission: "system:menu:list", // 需要菜单列表权限
},
{
key: "teacher",
title: "角色管理",
icon: "UserOutlined",
path: "/admin/teacher",
component: "Role.vue",
permission: "system:role:list", // 需要角色列表权限
},
{
key: "notification-management",
title: "通知管理",
icon: "UserOutlined",
path: "/admin/notification-management",
component: "NotificationManagement.vue",
permission: "studio:notice:list", // 需要通知列表权限
},
{
key: "log-management",
title: "日志管理",
icon: "FileSearchOutlined",
path: "/admin/log-management",
component: "LogManagement.vue",
permission: "log.view_operationlog", // 查看操作日志权限
},
{
key: "knowledge-base",
title: "知识库管理",
icon: "DatabaseOutlined",
path: "/admin/knowledge-base",
component: "KnowledgeBaseManagement.vue",
// 菜单访问权限:需要具备文章列表权限
permission: "system:knowledge:article:list",
},
{
key: "yolo-model",
title: "YOLO模型",
icon: "ClusterOutlined",
path: "/admin/yolo-model",
component: "ModelManagement.vue", // 默认显示模型管理页面
// 父菜单不设置权限,由子菜单的权限决定是否显示
children: [
{
key: "model-management",
title: "模型管理",
icon: "RobotOutlined",
path: "/admin/model-management",
component: "ModelManagement.vue",
permission: "system:yolo:model:list",
},
{
key: "alert-level-management",
title: "模型类别管理",
icon: "WarningOutlined",
path: "/admin/alert-level-management",
component: "AlertLevelManagement.vue",
permission: "system:yolo:alert:list",
},
{
key: "alert-processing-center",
title: "告警处理中心",
icon: "BellOutlined",
path: "/admin/alert-processing-center",
component: "AlertProcessingCenter.vue",
permission: "system:yolo:alert:process",
},
{
key: "detection-history-management",
title: "检测历史管理",
icon: "HistoryOutlined",
path: "/admin/detection-history-management",
component: "DetectionHistoryManagement.vue",
permission: "system:yolo:history:list",
},
],
},
];
// 默认管理员角色 - 修改为空数组,通过自定义权限检查函数处理
const DEFAULT_ADMIN_ROLES: UserRole[] = [];
// 组件映射 - 静态导入以支持Vite分析
const COMPONENT_MAP: { [key: string]: () => Promise<any> } = {
'Dashboard.vue': () => import("@/views/admin_page/Dashboard.vue"),
'UserManagement.vue': () => import("@/views/admin_page/UserManagement.vue"),
'DepartmentManagement.vue': () => import("@/views/admin_page/DepartmentManagement.vue"),
'Role.vue': () => import("@/views/admin_page/Role.vue"),
'MenuManagement.vue': () => import("@/views/admin_page/MenuManagement.vue"),
'NotificationManagement.vue': () => import("@/views/admin_page/NotificationManagement.vue"),
'LogManagement.vue': () => import("@/views/admin_page/LogManagement.vue"),
'KnowledgeBaseManagement.vue': () => import("@/views/admin_page/KnowledgeBaseManagement.vue"),
'ModelManagement.vue': () => import("@/views/admin_page/ModelManagement.vue"),
'AlertLevelManagement.vue': () => import("@/views/admin_page/AlertLevelManagement.vue"),
'AlertProcessingCenter.vue': () => import("@/views/admin_page/AlertProcessingCenter.vue"),
'DetectionHistoryManagement.vue': () => import("@/views/admin_page/DetectionHistoryManagement.vue"),
};
// 🚀 自动生成路由配置
function generateAdminRoutes(): RouteRecordRaw {
const children: RouteRecordRaw[] = [];
ADMIN_MENU_CONFIG.forEach(item => {
// 如果有子菜单,将子菜单作为独立的路由项
if (item.children && item.children.length > 0) {
// 为每个子菜单创建独立的路由
item.children.forEach(child => {
children.push({
path: child.path.replace("/admin/", ""),
name: child.key,
component: COMPONENT_MAP[child.component] || (() => import("@/views/admin_page/Dashboard.vue")),
meta: {
title: child.title,
requiresAuth: true,
roles: child.roles || DEFAULT_ADMIN_ROLES,
},
});
});
} else {
// 没有子菜单的普通菜单项
children.push({
path: item.isDefault ? "" : item.path.replace("/admin/", ""),
name: item.key,
component: COMPONENT_MAP[item.component] || (() => import("@/views/admin_page/Dashboard.vue")),
meta: {
title: item.title,
requiresAuth: true,
roles: item.roles || DEFAULT_ADMIN_ROLES,
},
});
}
});
console.log('🛣️ 生成的管理端路由配置:', children.map(child => ({
path: child.path,
name: child.name,
title: child.meta?.title
})));
return {
path: "/admin",
name: "Admin",
component: () => import("@/views/admin_page/index.vue"),
meta: {
title: "管理后台",
requiresAuth: true,
roles: DEFAULT_ADMIN_ROLES,
},
children,
};
}
// 🚀 自动生成菜单配置
export interface MenuConfig {
key: string;
title: string;
icon?: string;
path: string;
children?: MenuConfig[];
}
function generateMenuConfig(): MenuConfig[] {
return ADMIN_MENU_CONFIG.map(item => ({
key: item.key,
title: item.title,
icon: item.icon,
path: item.path,
children: item.children?.map(child => ({
key: child.key,
title: child.title,
icon: child.icon,
path: child.path,
})),
}));
}
// 🚀 自动生成路径映射函数
function generatePathKeyMapping(): { [path: string]: string } {
const mapping: { [path: string]: string } = {};
function addToMapping(items: AdminMenuItem[], parentPath = '') {
items.forEach(item => {
mapping[item.path] = item.key;
if (item.children) {
addToMapping(item.children, item.path);
}
});
}
addToMapping(ADMIN_MENU_CONFIG);
return mapping;
}
// 导出的配置和函数
export const adminMenuRoutes: RouteRecordRaw = generateAdminRoutes();
export const adminMenuConfig: MenuConfig[] = generateMenuConfig();
// 路径到key的映射
const pathKeyMapping = generatePathKeyMapping();
// 🎯 根据路径获取菜单key - 自动生成
export const getMenuKeyByPath = (path: string): string => {
// 精确匹配
if (pathKeyMapping[path]) {
return pathKeyMapping[path];
}
// 模糊匹配
for (const [mappedPath, key] of Object.entries(pathKeyMapping)) {
if (path.includes(mappedPath) && mappedPath !== '/admin') {
return key;
}
}
// 默认返回dashboard
return 'dashboard';
};
// 🎯 根据菜单key获取路径 - 自动生成
export const getPathByMenuKey = (key: string): string => {
console.log('🔍 查找菜单路径:', key);
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === key);
if (menuItem) {
console.log('✅ 找到父菜单路径:', menuItem.path);
return menuItem.path;
}
// 在子菜单中查找
for (const item of ADMIN_MENU_CONFIG) {
if (item.children) {
const childItem = item.children.find(child => child.key === key);
if (childItem) {
console.log('✅ 找到子菜单路径:', childItem.path);
return childItem.path;
}
}
}
console.log('❌ 未找到菜单路径,返回默认路径');
return '/admin';
};
// 🎯 根据菜单key获取标题 - 自动生成
export const getTitleByMenuKey = (key: string): string => {
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === key);
if (menuItem) return menuItem.title;
// 在子菜单中查找
for (const item of ADMIN_MENU_CONFIG) {
if (item.children) {
const childItem = item.children.find(child => child.key === key);
if (childItem) return childItem.title;
}
}
return '仪表盘';
};
// 菜单权限检查
export const hasMenuPermission = (menuKey: string, userRole: string): boolean => {
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === menuKey);
if (!menuItem) return false;
return menuItem.roles ? menuItem.roles.includes(userRole as UserRole) : DEFAULT_ADMIN_ROLES.includes(userRole as UserRole);
};
// 🎯 新增:根据用户权限过滤菜单配置
export const getFilteredMenuConfig = (userRoles: string[], userPermissions: string[], userMenuPermissions?: number[]): MenuConfig[] => {
const userRole = userRoles[0]; // 取第一个角色作为主要角色
// 仅管理员角色显示管理端菜单
const adminRoles = ['admin', 'system_admin', 'super_admin'];
const isAdminRole = userRoles.some(r => adminRoles.includes(r));
if (!isAdminRole) {
return [];
}
// 对 super_admin / system_admin 开放所有管理菜单(忽略权限字符串过滤)
const isPrivilegedAdmin = userRoles.includes('super_admin') || userRoles.includes('system_admin');
// 过滤菜单项 - 基于权限字符串检查
const filteredMenus = ADMIN_MENU_CONFIG.filter(menuItem => {
console.log(`🔍 检查菜单项: ${menuItem.title} (${menuItem.key})`, {
hasPermission: !!menuItem.permission,
permission: menuItem.permission,
hasChildren: !!(menuItem.children && menuItem.children.length > 0),
childrenCount: menuItem.children?.length || 0
});
// 如果菜单没有配置权限要求,则默认允许访问(如仪表盘)
if (!menuItem.permission) {
console.log(`✅ 菜单 ${menuItem.title} 无权限要求,允许访问`);
return true;
}
// 检查用户是否有该菜单所需的权限
const hasMenuPermission = isPrivilegedAdmin ? true : hasPermission(menuItem.permission, userPermissions);
if (!hasMenuPermission) {
console.log(`❌ 菜单 ${menuItem.title} 权限不足,拒绝访问`);
return false;
}
// 如果有子菜单,过滤子菜单
if (menuItem.children && menuItem.children.length > 0) {
const filteredChildren = menuItem.children.filter(child => {
// 如果子菜单没有配置权限要求,则默认允许访问
if (!child.permission) {
console.log(`✅ 子菜单 ${child.title} 无权限要求,允许访问`);
return true;
}
const childHasPermission = hasPermission(child.permission, userPermissions);
console.log(`🔍 子菜单 ${child.title} 权限检查:`, {
permission: child.permission,
hasPermission: childHasPermission
});
return childHasPermission;
});
console.log(`📊 菜单 ${menuItem.title} 子菜单过滤结果:`, {
originalCount: menuItem.children.length,
filteredCount: filteredChildren.length,
filteredChildren: filteredChildren.map(c => c.title)
});
// 如果没有任何子菜单有权限,则不显示父菜单
if (filteredChildren.length === 0) {
console.log(`❌ 菜单 ${menuItem.title} 所有子菜单都无权限,隐藏父菜单`);
return false;
}
// 更新子菜单列表
menuItem.children = filteredChildren;
}
console.log(`✅ 菜单 ${menuItem.title} 通过权限检查`);
return true;
}).map(menuItem => ({
key: menuItem.key,
title: menuItem.title,
icon: menuItem.icon,
path: menuItem.path,
children: menuItem.children?.map(child => ({
key: child.key,
title: child.title,
icon: child.icon,
path: child.path
}))
}));
return filteredMenus;
};
// 🎯 新增:检查用户是否有任何管理员菜单权限
// 修改逻辑只有normal_user角色不能访问管理端其他所有角色都可以访问
export const hasAnyAdminPermission = (userRoles: string[]): boolean => {
// 仅当包含 admin/system_admin/super_admin 之一才视为管理员
const adminRoles = ['admin', 'system_admin', 'super_admin'];
return userRoles.some(role => adminRoles.includes(role));
};
/**
* 检查用户是否有指定权限
*/
const hasPermission = (permission: string, userPermissions: string[]): boolean => {
return userPermissions.includes(permission);
};

View File

@@ -1,275 +0,0 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import { useUserStore } from "@/stores/hertz_user";
import { adminMenuRoutes, UserRole } from "./admin_menu";
import { userRoutes } from "./user_menu_ai";
// 固定路由配置
const fixedRoutes: RouteRecordRaw[] = [
{
path: "/",
name: "Home",
component: () => import("@/views/Home.vue"),
meta: {
title: "首页",
requiresAuth: false,
},
children: [...generateDynamicRoutes("public")],
},
{
path: "/login",
name: "Login",
component: () => import("@/views/Login.vue"),
meta: {
title: "登录",
requiresAuth: false,
},
},
{
path: "/register",
name: "Register",
component: () => import("@/views/register.vue"),
meta: {
title: "注册",
requiresAuth: false,
},
},
// 管理端路由 - 从admin_menu.ts导入
adminMenuRoutes,
];
// 动态生成路由配置
function generateDynamicRoutes(targetDir: string = ""): RouteRecordRaw[] {
if (!targetDir) {
return [];
}
const viewsContext = import.meta.glob("@/views/**/*.vue", { eager: true });
return Object.entries(viewsContext)
.map(([path, component]) => {
const relativePath = path.match(/\/views\/(.+?)\.vue$/)?.[1];
if (!relativePath) return null;
const fileName = relativePath.replace(".vue", "");
const routeName = fileName.split("/").pop()!;
// 过滤条件
if (targetDir && !fileName.startsWith(targetDir)) {
return null;
}
// 生成路径和标题
const routePath = `/${fileName.replace(/([A-Z])/g, "$1").toLowerCase()}`;
const requiresAuth =
(!routePath.startsWith("/demo") && !routePath.startsWith("/public")) || routePath.startsWith("/user_pages")&& routePath.startsWith("/admin_page");
const pageTitle = (component as any)?.default?.title;
// 根据路径设置角色权限
let roles: UserRole[] = [];
if (routePath.startsWith("/admin_page")) {
roles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
} else if (routePath.startsWith("/user_pages")) {
roles = [UserRole.NORMAL_USER, UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
} else if (routePath.startsWith("/demo")) {
roles = []; // demo页面不需要特定角色
}
return {
path: routePath,
name: routeName,
component: () => import(/* @vite-ignore */ path),
meta: {
title: pageTitle,
requiresAuth,
roles: requiresAuth ? roles : []
},
};
})
.filter(Boolean) as RouteRecordRaw[];
}
// 合并固定路由和动态路由
const routes: RouteRecordRaw[] = [
...fixedRoutes,
...userRoutes, // 用户菜单路由 - 现在通过统一配置自动生成
...generateDynamicRoutes("demo"), // 生成demo文件夹的路由
...generateDynamicRoutes("admin_page"),//生成admin_page文件夹的路由
// 404页面始终放在最后
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("@/views/NotFound.vue"),
meta: {
title: "页面未找到",
requiresAuth: false,
},
},
];
// 创建路由实例
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(_to, _from, savedPosition) {
if (savedPosition) {
return savedPosition;
} else {
return { top: 0 };
}
},
});
// 递归打印路由信息
function printRoute(route: RouteRecordRaw, level: number = 0) {
const indent = " ".repeat(level);
const icon = route.meta.requiresAuth ? "🔒" : "🔓";
const auth = route.meta.requiresAuth ? "需要登录" : "公开访问";
console.log(`${indent}${icon} ${route.path}${route.meta.title} (${auth})`);
// 递归打印子路由
if (route.children && route.children.length > 0) {
route.children.forEach((child) => printRoute(child, level + 1));
}
}
// 路由调试信息
function logRouteInfo() {
console.log("🚀 管理系统 路由配置:");
console.log("📋 路由列表:");
routes.forEach((route) => printRoute(route));
console.log(" ❓ /:pathMatch(.*)* → NotFound (页面未找到)");
console.log("✅ 路由配置完成!");
}
// 重定向计数器,防止无限重定向
let redirectCount = 0;
const MAX_REDIRECTS = 3;
// 路由守卫
router.beforeEach((to, _from, next) => {
const userStore = useUserStore();
// 调试信息
console.log('🛡️ 路由守卫检查');
console.log('📍 目标路由:', to.path, to.name);
console.log('🔐 需要认证:', to.meta.requiresAuth);
console.log('👤 用户登录状态:', userStore.isLoggedIn);
console.log('🎫 Token:', userStore.token ? '存在' : '不存在');
console.log('📋 用户信息:', userStore.userInfo);
console.log('🔄 重定向计数:', redirectCount);
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 管理系统`;
}
// 检查是否需要登录
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
console.log('❌ 需要登录但用户未登录,重定向到登录页');
redirectCount++;
if (redirectCount > MAX_REDIRECTS) {
console.log('⚠️ 重定向次数过多,强制跳转到首页');
redirectCount = 0;
next({ name: "Home" });
return;
}
next({ name: "Login", query: { redirect: to.fullPath } });
return;
}
// 已登录用户访问登录页,根据角色重定向到对应首页
if (to.name === "Login" && userStore.isLoggedIn) {
const userRole = userStore.userInfo?.roles?.[0]?.role_code;
console.log('🔄 路由守卫 - 已登录用户访问登录页');
console.log('👤 当前用户角色:', userRole);
console.log('📋 用户信息:', userStore.userInfo);
// 重置重定向计数器
redirectCount = 0;
// 仅管理员角色进入管理端,其余(含未定义)进入用户端
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
const isAdmin = adminRoles.includes(userRole as UserRole);
if (isAdmin) {
console.log('➡️ 重定向到管理端首页');
next({ name: "Admin" });
} else {
console.log('➡️ 重定向到用户端首页');
next({ name: "UserDashboard" });
}
return;
}
// 检查角色权限
if (to.meta.requiresAuth && to.meta.roles && Array.isArray(to.meta.roles)) {
const userRole = userStore.userInfo?.roles?.[0]?.role_code;
// 特殊处理:如果是管理端路由,使用自定义权限检查
let hasPermission = false;
if (to.path.startsWith('/admin')) {
// 管理端路由:仅 admin/system_admin/super_admin 可访问
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
hasPermission = adminRoles.includes(userRole as UserRole);
} else {
// 其他路由:使用原有的角色检查逻辑
hasPermission = to.meta.roles.length === 0 || to.meta.roles.includes(userRole as UserRole);
}
console.log('🔐 路由权限检查');
console.log('📍 目标路由:', to.path, to.name);
console.log('🎭 需要的角色:', to.meta.roles);
console.log('👤 用户角色:', userRole);
console.log('🏢 是否为管理端路由:', to.path.startsWith('/admin'));
console.log('✅ 是否有权限:', hasPermission);
if (!hasPermission) {
console.log('❌ 权限不足,准备重定向');
// 增加重定向计数
redirectCount++;
// 防止无限重定向
if (redirectCount > MAX_REDIRECTS) {
console.log('⚠️ 重定向次数过多,强制跳转到首页');
redirectCount = 0;
next({ name: "Home" });
return;
}
// 防止无限重定向:检查是否已经在重定向过程中
if (to.name === 'Admin' || to.name === 'UserDashboard') {
console.log('⚠️ 检测到重定向循环,强制跳转到首页');
redirectCount = 0;
next({ name: "Home" });
return;
}
// 没有权限,根据用户角色重定向到对应首页
// 只有normal_user角色跳转到用户端其他角色包括未定义的都跳转到管理端
if (userRole === 'normal_user') {
console.log('➡️ 重定向到用户端首页');
next({ name: "UserDashboard" });
} else {
console.log('➡️ 重定向到管理端首页 (角色:', userRole || '未定义', ')');
next({ name: "Admin" });
}
return;
}
}
// 成功通过所有检查,重置重定向计数器
redirectCount = 0;
next();
});
// 路由错误处理
router.onError((error) => {
console.error("路由错误:", error);
});
// 输出路由信息
logRouteInfo();
export default router;

View File

@@ -1,183 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
import { defineAsyncComponent } from 'vue'
export interface UserMenuConfig {
key: string
label: string
icon?: string
path: string
component: string
children?: UserMenuConfig[]
disabled?: boolean
meta?: {
title?: string
requiresAuth?: boolean
roles?: string[]
[key: string]: any
}
}
export interface MenuItem {
key: string
label: string
icon?: string
path?: string
children?: MenuItem[]
disabled?: boolean
}
export const userMenuConfigs: UserMenuConfig[] = [
{ key: 'dashboard', label: '首页', icon: 'DashboardOutlined', path: '/dashboard', component: 'index.vue', meta: { title: '用户首页', requiresAuth: true } },
{ key: 'profile', label: '个人信息', icon: 'UserOutlined', path: '/user/profile', component: 'Profile.vue', meta: { title: '个人信息', requiresAuth: true, hideInMenu: true } },
// { key: 'documents', label: '文档管理', icon: 'FileTextOutlined', path: '/user/documents', component: 'Documents.vue', meta: { title: '文档管理', requiresAuth: true } },
{ key: 'system-monitor', label: '系统监控', icon: 'DashboardOutlined', path: '/user/system-monitor', component: 'SystemMonitor.vue', meta: { title: '系统监控', requiresAuth: true } },
{ key: 'ai-chat', label: 'AI助手', icon: 'MessageOutlined', path: '/user/ai-chat', component: 'AiChat.vue', meta: { title: 'AI助手', requiresAuth: true } },
{ key: 'yolo-detection', label: 'YOLO检测', icon: 'ScanOutlined', path: '/user/yolo-detection', component: 'YoloDetection.vue', meta: { title: 'YOLO检测中心', requiresAuth: true } },
{ key: 'live-detection', label: '实时检测', icon: 'VideoCameraOutlined', path: '/user/live-detection', component: 'LiveDetection.vue', meta: { title: '实时检测', requiresAuth: true } },
{ key: 'detection-history', label: '检测历史', icon: 'HistoryOutlined', path: '/user/detection-history', component: 'DetectionHistory.vue', meta: { title: '检测历史记录', requiresAuth: true } },
{ key: 'alert-center', label: '告警中心', icon: 'ExclamationCircleOutlined', path: '/user/alert-center', component: 'AlertCenter.vue', meta: { title: '告警中心', requiresAuth: true } },
{ key: 'notice-center', label: '通知中心', icon: 'BellOutlined', path: '/user/notice', component: 'NoticeCenter.vue', meta: { title: '通知中心', requiresAuth: true } },
{ key: 'knowledge-center', label: '知识库中心', icon: 'DatabaseOutlined', path: '/user/knowledge', component: 'KnowledgeCenter.vue', meta: { title: '知识库中心', requiresAuth: true } },
]
const explicitComponentMap: Record<string, any> = {
'index.vue': defineAsyncComponent(() => import('@/views/user_pages/index.vue')),
'Profile.vue': defineAsyncComponent(() => import('@/views/user_pages/Profile.vue')),
'Documents.vue': defineAsyncComponent(() => import('@/views/user_pages/Documents.vue')),
'Messages.vue': defineAsyncComponent(() => import('@/views/user_pages/Messages.vue')),
'SystemMonitor.vue': defineAsyncComponent(() => import('@/views/user_pages/SystemMonitor.vue')),
'AiChat.vue': defineAsyncComponent(() => import('@/views/user_pages/AiChat.vue')),
'YoloDetection.vue': defineAsyncComponent(() => import('@/views/user_pages/YoloDetection.vue')),
'LiveDetection.vue': defineAsyncComponent(() => import('@/views/user_pages/LiveDetection.vue')),
'DetectionHistory.vue': defineAsyncComponent(() => import('@/views/user_pages/DetectionHistory.vue')),
'AlertCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/AlertCenter.vue')),
'NoticeCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/NoticeCenter.vue')),
'KnowledgeCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/KnowledgeCenter.vue')),
}
export const userMenuItems: MenuItem[] = userMenuConfigs.map(config => ({
key: config.key,
label: config.label,
icon: config.icon,
path: config.path,
disabled: config.disabled,
children: config.children?.map(child => ({ key: child.key, label: child.label, icon: child.icon, path: child.path, disabled: child.disabled }))
}))
const componentMap: Record<string, () => Promise<any>> = {
'index.vue': () => import('@/views/user_pages/index.vue'),
'Profile.vue': () => import('@/views/user_pages/Profile.vue'),
'Documents.vue': () => import('@/views/user_pages/Documents.vue'),
'Messages.vue': () => import('@/views/user_pages/Messages.vue'),
'SystemMonitor.vue': () => import('@/views/user_pages/SystemMonitor.vue'),
'AiChat.vue': () => import('@/views/user_pages/AiChat.vue'),
'YoloDetection.vue': () => import('@/views/user_pages/YoloDetection.vue'),
'LiveDetection.vue': () => import('@/views/user_pages/LiveDetection.vue'),
'DetectionHistory.vue': () => import('@/views/user_pages/DetectionHistory.vue'),
'AlertCenter.vue': () => import('@/views/user_pages/AlertCenter.vue'),
'NoticeCenter.vue': () => import('@/views/user_pages/NoticeCenter.vue'),
'KnowledgeCenter.vue': () => import('@/views/user_pages/KnowledgeCenter.vue'),
}
const baseRoutes: RouteRecordRaw[] = userMenuConfigs.map(config => {
const route: RouteRecordRaw = {
path: config.path,
name: `User${config.key.charAt(0).toUpperCase() + config.key.slice(1)}`,
component: componentMap[config.component] || (() => import('@/views/NotFound.vue')),
meta: { title: config.meta?.title || config.label, requiresAuth: config.meta?.requiresAuth ?? true, ...config.meta }
}
if (config.children && config.children.length > 0) {
route.children = config.children.map(child => ({
path: child.path,
name: `User${child.key.charAt(0).toUpperCase() + child.key.slice(1)}`,
component: componentMap[child.component] || (() => import('@/views/NotFound.vue')),
meta: { title: child.meta?.title || child.label, requiresAuth: child.meta?.requiresAuth ?? true, ...child.meta }
}))
}
return route
})
// 文章详情独立页面(不在菜单展示)
const knowledgeDetailRoute: RouteRecordRaw = {
path: '/user/knowledge/:id',
name: 'UserKnowledgeDetail',
component: () => import('@/views/user_pages/KnowledgeDetail.vue'),
meta: { title: '文章详情', requiresAuth: true, hideInMenu: true }
}
export const userRoutes: RouteRecordRaw[] = [...baseRoutes, knowledgeDetailRoute]
export function getMenuPath(menuKey: string): string {
const findPath = (items: MenuItem[], key: string): string | null => {
for (const item of items) {
if (item.key === key && item.path) return item.path
if (item.children) {
const childPath = findPath(item.children, key)
if (childPath) return childPath
}
}
return null
}
return findPath(userMenuItems, menuKey) || '/dashboard'
}
export function getMenuBreadcrumb(menuKey: string): string[] {
const findBreadcrumb = (items: MenuItem[], key: string, path: string[] = []): string[] | null => {
for (const item of items) {
const currentPath = [...path, item.label]
if (item.key === menuKey) return currentPath
if (item.children) {
const childPath = findBreadcrumb(item.children, key, currentPath)
if (childPath) return childPath
}
}
return null
}
return findBreadcrumb(userMenuItems, menuKey) || ['仪表盘']
}
export const generateComponentMap = () => {
const map: Record<string, any> = {}
const processConfigs = (configs: UserMenuConfig[]) => {
configs.forEach(config => {
if (explicitComponentMap[config.component]) {
map[config.key] = explicitComponentMap[config.component]
} else {
map[config.key] = defineAsyncComponent(() => import('@/views/NotFound.vue'))
}
if (config.children) processConfigs(config.children)
})
}
processConfigs(userMenuConfigs)
return map
}
export const userComponentMap = generateComponentMap()
export const getFilteredUserMenuItems = (userRoles: string[], userPermissions: string[]): MenuItem[] => {
return userMenuConfigs
.filter(config => {
// 隐藏菜单中不显示的项(如个人信息,只在用户下拉菜单中显示)
if (config.meta?.hideInMenu) return false
if (!config.meta?.roles || config.meta.roles.length === 0) return true
return config.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
})
.map(config => ({
key: config.key,
label: config.label,
icon: config.icon,
path: config.path,
disabled: config.disabled,
children: config.children?.filter(child => {
if (!child.meta?.roles || child.meta.roles.length === 0) return true
return child.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
}).map(child => ({ key: child.key, label: child.label, icon: child.icon, path: child.path, disabled: child.disabled }))
}))
}
export const hasUserMenuPermission = (menuKey: string, userRoles: string[]): boolean => {
const menuConfig = userMenuConfigs.find(config => config.key === menuKey)
if (!menuConfig) return false
if (!menuConfig.meta?.roles || menuConfig.meta.roles.length === 0) return true
return menuConfig.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
}

View File

@@ -1,98 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { i18n } from '@/locales'
// 主题类型
export type Theme = 'light' | 'dark' | 'auto'
// 语言类型
export type Language = 'zh-CN' | 'en-US'
export const useAppStore = defineStore('app', () => {
// 状态
const theme = ref<Theme>('light')
const language = ref<Language>('zh-CN')
const collapsed = ref<boolean>(false)
const loading = ref<boolean>(false)
// 计算属性
const isDark = computed(() => {
if (theme.value === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return theme.value === 'dark'
})
const currentLanguage = computed(() => language.value)
// 方法
const setTheme = (newTheme: Theme) => {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
// 应用主题到HTML
const html = document.documentElement
if (newTheme === 'dark' || (newTheme === 'auto' && isDark.value)) {
html.classList.add('dark')
} else {
html.classList.remove('dark')
}
}
const setLanguage = (newLanguage: Language) => {
language.value = newLanguage
localStorage.setItem('language', newLanguage)
// 设置i18n语言
i18n.global.locale.value = newLanguage
}
const toggleCollapsed = () => {
collapsed.value = !collapsed.value
}
const setLoading = (state: boolean) => {
loading.value = state
}
const initAppSettings = () => {
// 从本地存储恢复设置
const savedTheme = localStorage.getItem('theme') as Theme
const savedLanguage = localStorage.getItem('language') as Language
if (savedTheme) {
setTheme(savedTheme)
}
if (savedLanguage) {
setLanguage(savedLanguage)
} else {
// 根据浏览器语言自动设置
const browserLang = navigator.language
if (browserLang.startsWith('zh')) {
setLanguage('zh-CN')
} else {
setLanguage('en-US')
}
}
}
return {
// 状态
theme,
language,
collapsed,
loading,
// 计算属性
isDark,
currentLanguage,
// 方法
setTheme,
setLanguage,
toggleCollapsed,
setLoading,
initAppSettings,
}
})

View File

@@ -1,101 +0,0 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
// 主题配置接口
export interface ThemeConfig {
// 导航栏
headerBg: string
headerText: string
headerBorder: string
// 背景
pageBg: string
contentBg: string
// 组件背景
cardBg: string
cardBorder: string
// 主色调
primaryColor: string
textPrimary: string
textSecondary: string
}
// 默认主题
const defaultTheme: ThemeConfig = {
headerBg: '#ffffff',
headerText: '#111827',
headerBorder: '#e5e7eb',
pageBg: '#ffffff',
contentBg: '#ffffff',
cardBg: '#ffffff',
cardBorder: '#e5e7eb',
primaryColor: '#2563eb',
textPrimary: '#111827',
textSecondary: '#6b7280',
}
export const useThemeStore = defineStore('theme', () => {
const theme = ref<ThemeConfig>({ ...defaultTheme })
// 从 localStorage 加载主题
const loadTheme = () => {
const savedTheme = localStorage.getItem('customTheme')
if (savedTheme) {
try {
theme.value = { ...defaultTheme, ...JSON.parse(savedTheme) }
applyTheme(theme.value)
} catch (e) {
console.error('Failed to load theme:', e)
}
} else {
applyTheme(theme.value)
}
}
// 应用主题
const applyTheme = (config: ThemeConfig) => {
const root = document.documentElement
// 设置 CSS 变量
root.style.setProperty('--theme-header-bg', config.headerBg)
root.style.setProperty('--theme-header-text', config.headerText)
root.style.setProperty('--theme-header-border', config.headerBorder)
root.style.setProperty('--theme-page-bg', config.pageBg)
root.style.setProperty('--theme-content-bg', config.contentBg)
root.style.setProperty('--theme-card-bg', config.cardBg)
root.style.setProperty('--theme-card-border', config.cardBorder)
root.style.setProperty('--theme-primary', config.primaryColor)
root.style.setProperty('--theme-text-primary', config.textPrimary)
root.style.setProperty('--theme-text-secondary', config.textSecondary)
}
// 更新主题
const updateTheme = (newTheme: Partial<ThemeConfig>) => {
theme.value = { ...theme.value, ...newTheme }
applyTheme(theme.value)
localStorage.setItem('customTheme', JSON.stringify(theme.value))
}
// 重置主题
const resetTheme = () => {
theme.value = { ...defaultTheme }
applyTheme(theme.value)
localStorage.removeItem('customTheme')
}
// 监听主题变化,自动应用
watch(theme, (newTheme) => {
applyTheme(newTheme)
}, { deep: true })
return {
theme,
loadTheme,
updateTheme,
resetTheme,
applyTheme,
}
})

View File

@@ -1,246 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { request } from '@/utils/hertz_request'
import { changePassword } from '@/api/password'
import type { ChangePasswordParams } from '@/api/password'
import { roleApi } from '@/api/role'
import { initializeMenuMapping } from '@/utils/menu_mapping'
import { logoutUser } from '@/api/auth'
// 用户信息接口
interface UserInfo {
user_id: number
username: string
email: string
phone?: string
real_name?: string
avatar?: string
roles: Array<{
role_id: number
role_name: string
role_code: string
}>
permissions: string[]
menu_permissions?: number[] // 用户拥有的菜单权限ID列表
}
// 登录参数接口
interface LoginParams {
username: string
password: string
remember?: boolean
}
export const useUserStore = defineStore('user', () => {
// 状态
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
const isLoggedIn = ref<boolean>(false)
const loading = ref<boolean>(false)
const userMenuPermissions = ref<number[]>([]) // 用户菜单权限ID列表
// 计算属性
const hasPermission = computed(() => (permission: string) => {
return userInfo.value?.permissions?.includes(permission) || false
})
const isAdmin = computed(() => {
const userRole = userInfo.value?.roles?.[0]?.role_code
return userRole === 'admin' || userRole === 'system_admin' || userRole === 'super_admin'
})
// 方法
const login = async (params: LoginParams) => {
loading.value = true
try {
const response = await request.post<{
access_token: string
refresh_token: string
user_info: UserInfo
}>('/api/auth/login/', params)
token.value = response.access_token
userInfo.value = response.user_info
isLoggedIn.value = true
// 保存到本地存储
localStorage.setItem('token', response.access_token)
if (params.remember) {
localStorage.setItem('userInfo', JSON.stringify(response.user_info))
}
// 获取用户菜单权限
await fetchUserMenuPermissions()
return response
} catch (error) {
console.error('登录失败:', error)
throw error
} finally {
loading.value = false
}
}
const logout = async () => {
loading.value = true
try {
// 调用封装好的退出登录接口
await logoutUser()
// 清除状态
token.value = ''
userInfo.value = null
isLoggedIn.value = false
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
} catch (error) {
console.error('退出登录失败:', error)
// 即使请求失败也要清除本地状态
token.value = ''
userInfo.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
} finally {
loading.value = false
}
}
const updateUserInfo = async (info: Partial<UserInfo>) => {
try {
const response = await request.put<UserInfo>('/user/profile', info)
userInfo.value = { ...userInfo.value, ...response }
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
return response
} catch (error) {
console.error('更新用户信息失败:', error)
throw error
}
}
const checkAuth = async () => {
console.log('🔍 检查用户认证状态...')
const savedToken = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('userInfo')
console.log('💾 localStorage中的token:', savedToken ? '存在' : '不存在')
console.log('💾 localStorage中的userInfo:', savedUserInfo ? '存在' : '不存在')
if (savedToken && savedUserInfo) {
try {
const parsedUserInfo = JSON.parse(savedUserInfo)
token.value = savedToken
userInfo.value = parsedUserInfo
isLoggedIn.value = true
console.log('✅ 用户状态恢复成功')
console.log('👤 恢复的用户信息:', parsedUserInfo)
console.log('🔐 登录状态:', isLoggedIn.value)
// 获取用户菜单权限
await fetchUserMenuPermissions()
} catch (error) {
console.error('❌ 解析用户信息失败:', error)
clearAuth()
}
} else {
console.log('❌ 没有找到保存的认证信息')
}
}
const clearAuth = () => {
token.value = ''
userInfo.value = null
isLoggedIn.value = false
userMenuPermissions.value = []
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
const updatePassword = async (params: ChangePasswordParams) => {
try {
await changePassword(params)
return true
} catch (error) {
console.error('修改密码失败:', error)
throw error
}
}
// 获取用户菜单权限
const fetchUserMenuPermissions = async () => {
if (!userInfo.value?.roles?.length) {
userMenuPermissions.value = []
return []
}
try {
// 获取用户所有角色的菜单权限
const allMenuPermissions = new Set<number>()
for (const role of userInfo.value.roles) {
try {
const response = await roleApi.getRolePermissions(role.role_id)
if (response.success) {
const menuIds = response.data.list || response.data
if (Array.isArray(menuIds)) {
menuIds.forEach((menuId: any) => {
const id = typeof menuId === 'number' ? menuId : Number(menuId)
if (!isNaN(id)) {
allMenuPermissions.add(id)
}
})
}
}
} catch (error) {
console.error(`获取角色 ${role.role_name} 的菜单权限失败:`, error)
}
}
const permissions = Array.from(allMenuPermissions)
userMenuPermissions.value = permissions
// 同时更新用户信息中的菜单权限
if (userInfo.value) {
userInfo.value.menu_permissions = permissions
}
// 初始化菜单映射关系
await initializeMenuMapping()
return permissions
} catch (error) {
console.error('获取用户菜单权限失败:', error)
userMenuPermissions.value = []
return []
}
}
return {
// 状态
userInfo,
token,
isLoggedIn,
loading,
userMenuPermissions,
// 计算属性
hasPermission,
isAdmin,
// 方法
login,
logout,
updateUserInfo,
checkAuth,
clearAuth,
updatePassword,
fetchUserMenuPermissions,
}
})

View File

@@ -1,79 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,422 +0,0 @@
// 全局样式入口文件
@use 'variables' as *;
@use 'sass:color';
// 全局样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #ffffff;
color: #111827;
}
#app {
height: 100%;
}
// 按钮样式
.btn {
@include transition(all);
padding: $spacing-3 $spacing-6;
border: 1px solid $gray-300;
border-radius: $radius-md;
background: $bg-primary;
cursor: pointer;
font-weight: 500;
font-size: $font-size-sm;
line-height: 1.5;
&:hover {
border-color: $primary-color;
}
&.btn-primary {
@include button-style($primary-color, white);
}
&.btn-secondary {
background: $bg-primary;
color: $gray-700;
border-color: $gray-300;
&:hover {
background: $gray-50;
border-color: $primary-color;
color: $primary-color;
}
}
&.btn-success {
@include button-style($success-color, white);
}
&.btn-danger {
@include button-style($error-color, white);
}
&.btn-warning {
@include button-style($warning-color, white);
}
}
// 卡片样式
.card {
@include card-style;
padding: $spacing-6;
margin-bottom: $spacing-4;
}
// 表单样式
.form-item {
margin-bottom: $spacing-4;
label {
display: block;
margin-bottom: $spacing-2;
font-weight: 500;
color: $gray-700;
font-size: $font-size-sm;
}
input, select, textarea {
width: 100%;
padding: $spacing-3;
border: 1px solid $gray-300;
border-radius: $radius-md;
font-size: $font-size-sm;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $primary-color;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
&:hover {
border-color: $gray-400;
}
}
}
// 布局辅助类
.flex-center {
@include flex-center;
}
.text-ellipsis {
@include text-ellipsis;
}
// 间距辅助类
.m-0 { margin: $spacing-0; }
.m-1 { margin: $spacing-1; }
.m-2 { margin: $spacing-2; }
.m-3 { margin: $spacing-3; }
.m-4 { margin: $spacing-4; }
.m-5 { margin: $spacing-5; }
.m-6 { margin: $spacing-6; }
.m-8 { margin: $spacing-8; }
.p-0 { padding: $spacing-0; }
.p-1 { padding: $spacing-1; }
.p-2 { padding: $spacing-2; }
.p-3 { padding: $spacing-3; }
.p-4 { padding: $spacing-4; }
.p-5 { padding: $spacing-5; }
.p-6 { padding: $spacing-6; }
.p-8 { padding: $spacing-8; }
// ==================== 全局弹窗美化样式 - 苹果风格 ====================
// 弹窗遮罩层
.ant-modal-mask {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// 弹窗容器
.ant-modal-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
// 统一按钮主题 - 苹果风格
.ant-btn {
border-radius: 12px;
font-weight: 500;
padding: 0 20px;
height: 40px;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
border-width: 0.5px;
&.ant-btn-default {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(0, 0, 0, 0.12);
color: #1d1d1f;
&:hover {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.16);
transform: translateY(-1px);
}
}
&.ant-btn-primary {
background: #3b82f6;
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
&:hover {
background: #2563eb;
border-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
&:active { transform: translateY(0); }
}
&.ant-btn-dangerous:not(.ant-btn-link) {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
&:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
}
&.ant-btn-link {
border-radius: 8px;
}
&.ant-btn-sm {
border-radius: 8px;
height: 30px;
padding: 0 14px;
}
}
// 弹窗内容 - 苹果风格
.ant-modal {
top: 0;
padding-bottom: 0;
.ant-modal-content {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: 20px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.2),
0 0 0 0.5px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: modalSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// 弹窗头部
.ant-modal-header {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 24px 28px;
border-radius: 20px 20px 0 0;
.ant-modal-title {
font-weight: 600;
color: #1d1d1f;
font-size: 20px;
letter-spacing: -0.3px;
line-height: 1.3;
}
.ant-modal-close {
top: 24px;
right: 28px;
width: 32px;
height: 32px;
border-radius: 8px;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.1);
}
.ant-modal-close-x {
width: 32px;
height: 32px;
line-height: 32px;
font-size: 16px;
color: #86868b;
transition: color 0.2s ease;
&:hover { color: #1d1d1f; }
}
}
}
// 弹窗主体
.ant-modal-body {
padding: 28px;
background: rgba(255, 255, 255, 0.95);
color: #1d1d1f;
line-height: 1.6;
}
// 弹窗底部
.ant-modal-footer {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 20px 28px;
border-radius: 0 0 20px 20px;
.ant-btn {
border-radius: 10px;
height: 40px;
padding: 0 20px;
font-weight: 500;
font-size: 14px;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
border: 0.5px solid rgba(0, 0, 0, 0.12);
&:not(.ant-btn-primary) {
background: rgba(255, 255, 255, 0.8);
color: #1d1d1f;
&:hover {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.16);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
&.ant-btn-primary {
background: #3b82f6;
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
&:hover {
background: #2563eb;
border-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
&:active { transform: translateY(0); }
}
&.ant-btn-dangerous {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
&:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
}
}
}
// 表单元素美化
.ant-form-item-label > label {
font-weight: 500;
color: #1d1d1f;
font-size: 14px;
letter-spacing: -0.1px;
}
.ant-input,
.ant-select-selector,
.ant-input-number,
.ant-picker,
.ant-textarea,
.ant-tree-select-selector {
border-radius: 10px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.9);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
font-size: 14px;
&:hover { border-color: #3b82f6; background: rgba(255, 255, 255, 1); }
&:focus,
&.ant-input-focused,
&.ant-select-focused .ant-select-selector,
&.ant-picker-focused { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); background: rgba(255, 255, 255, 1); }
}
.ant-input-number { width: 100%; }
.ant-radio-group {
.ant-radio-button-wrapper {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover { border-color: #3b82f6; }
&.ant-radio-button-wrapper-checked { background: #3b82f6; border-color: #3b82f6; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
}
}
.ant-switch { background: rgba(0, 0, 0, 0.25); &.ant-switch-checked { background: #10b981; } }
// 表格在弹窗中的样式
.ant-table {
background: transparent;
.ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
font-size: 13px;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td { background: rgba(0, 0, 0, 0.02); }
> td { padding: 16px; border-bottom: 0.5px solid rgba(0, 0, 0, 0.06); color: #1d1d1f; }
}
}
// 标签样式
.ant-tag { border-radius: 6px; font-weight: 500; padding: 2px 10px; border: 0.5px solid currentColor; opacity: 0.8; }
// 描述列表样式
.ant-descriptions {
.ant-descriptions-item-label { font-weight: 500; color: #1d1d1f; background: rgba(0, 0, 0, 0.02); }
.ant-descriptions-item-content { color: #86868b; }
}
}
// 弹窗动画
@keyframes modalSlideIn {
from { opacity: 0; transform: scale(0.95) translateY(-20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
// 响应式优化
@media (max-width: 768px) {
.ant-modal {
.ant-modal-content { border-radius: 16px; }
.ant-modal-header { padding: 20px 20px; border-radius: 16px 16px 0 0; .ant-modal-title { font-size: 18px; } }
.ant-modal-body { padding: 20px; }
.ant-modal-footer { padding: 16px 20px; border-radius: 0 0 16px 16px; }
}
}

View File

@@ -1,124 +0,0 @@
// 全局变量文件 - 简约现代风格
// 颜色系统
$primary-color: #2563eb;
$primary-light: #3b82f6;
$primary-dark: #1d4ed8;
$success-color: #10b981;
$warning-color: #f59e0b;
$error-color: #ef4444;
$info-color: #06b6d4;
// 中性色系统
$gray-50: #f9fafb;
$gray-100: #f3f4f6;
$gray-200: #e5e7eb;
$gray-300: #d1d5db;
$gray-400: #9ca3af;
$gray-500: #6b7280;
$gray-600: #4b5563;
$gray-700: #374151;
$gray-800: #1f2937;
$gray-900: #111827;
// 背景色
$bg-primary: #ffffff;
$bg-secondary: #f9fafb;
$bg-tertiary: #f3f4f6;
// 字体大小
$font-size-xs: 12px;
$font-size-sm: 14px;
$font-size-base: 16px;
$font-size-lg: 18px;
$font-size-xl: 20px;
$font-size-2xl: 24px;
$font-size-3xl: 30px;
$font-size-4xl: 36px;
// 间距系统 - 4px基础单位
$spacing-0: 0;
$spacing-1: 4px;
$spacing-2: 8px;
$spacing-3: 12px;
$spacing-4: 16px;
$spacing-5: 20px;
$spacing-6: 24px;
$spacing-8: 32px;
$spacing-10: 40px;
$spacing-12: 48px;
$spacing-16: 64px;
$spacing-20: 80px;
// 圆角系统
$radius-none: 0;
$radius-sm: 4px;
$radius-md: 6px;
$radius-lg: 8px;
$radius-xl: 12px;
$radius-2xl: 16px;
$radius-full: 9999px;
// 阴影系统
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
// 过渡时间
$transition-fast: 0.15s;
$transition-normal: 0.2s;
$transition-slow: 0.3s;
// 混合器
@mixin flex-center {
display: flex;
justify-content: center;
align-items: center;
}
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin box-shadow($shadow: $shadow-md) {
box-shadow: $shadow;
}
@mixin transition($property: all, $duration: $transition-normal) {
transition: #{$property} #{$duration} ease;
}
@mixin card-style {
background: $bg-primary;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
border: 1px solid $gray-200;
transition: all 0.2s ease;
&:hover {
box-shadow: $shadow-md;
}
}
@mixin button-style($bg-color: $primary-color, $text-color: white) {
background: $bg-color;
color: $text-color;
border: 1px solid $bg-color;
border-radius: $radius-md;
padding: $spacing-3 $spacing-6;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: darken($bg-color, 8%);
border-color: darken($bg-color, 8%);
}
&:active {
background: darken($bg-color, 12%);
}
}

View File

@@ -1,13 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
readonly VITE_DEV_SERVER_HOST: string
readonly VITE_DEV_SERVER_PORT: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,182 +0,0 @@
// 通用响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
success?: boolean
timestamp?: string
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
// 分页响应
export interface PageResponse<T> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// 用户相关类型
export interface User {
id: number
username: string
email: string
avatar?: string
role: string
permissions: string[]
status: 'active' | 'inactive' | 'banned'
createTime: string
updateTime: string
}
export interface LoginParams {
username: string
password: string
remember?: boolean
}
export interface LoginResponse {
token: string
user: User
expiresIn: number
}
// 菜单相关类型
export interface MenuItem {
id: number
name: string
path: string
icon?: string
children?: MenuItem[]
permission?: string
hidden?: boolean
meta?: {
title: string
requiresAuth?: boolean
}
}
// 表格相关类型
export interface TableColumn<T = any> {
key: string
title: string
width?: number
fixed?: 'left' | 'right'
sortable?: boolean
render?: (record: T, index: number) => any
}
export interface TableProps<T = any> {
data: T[]
columns: TableColumn<T>[]
loading?: boolean
pagination?: {
current: number
pageSize: number
total: number
showSizeChanger?: boolean
showQuickJumper?: boolean
}
rowSelection?: {
selectedRowKeys: (string | number)[]
onChange: (selectedRowKeys: (string | number)[], selectedRows: T[]) => void
}
}
// 表单相关类型
export interface FormField {
name: string
label: string
type: 'input' | 'select' | 'textarea' | 'date' | 'switch' | 'radio' | 'checkbox'
required?: boolean
placeholder?: string
options?: { label: string; value: any }[]
rules?: any[]
}
export interface FormProps {
fields: FormField[]
initialValues?: Record<string, any>
onSubmit: (values: Record<string, any>) => Promise<void>
loading?: boolean
}
// 弹窗相关类型
export interface ModalProps {
title: string
visible: boolean
onCancel: () => void
onOk?: () => void
width?: number
children: any
}
// 消息相关类型
export type MessageType = 'success' | 'error' | 'warning' | 'info'
export interface MessageConfig {
type: MessageType
content: string
duration?: number
}
// 主题相关类型
export type Theme = 'light' | 'dark' | 'auto'
// 语言相关类型
export type Language = 'zh-CN' | 'en-US'
// 路由相关类型
export interface RouteMeta {
title?: string
requiresAuth?: boolean
permission?: string
hidden?: boolean
icon?: string
}
// 组件属性类型
export interface ComponentProps {
className?: string
style?: Record<string, any>
children?: any
}
// 工具函数类型
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
// API 相关类型
export interface RequestConfig {
showLoading?: boolean
showError?: boolean
timeout?: number
}
// 文件相关类型
export interface FileInfo {
name: string
size: number
type: string
url?: string
lastModified: number
}
export interface UploadProps {
accept?: string
multiple?: boolean
maxSize?: number
onUpload: (files: File[]) => Promise<void>
onRemove?: (file: FileInfo) => void
}

View File

@@ -1,70 +0,0 @@
import { generateCaptcha, refreshCaptcha, type CaptchaResponse, type CaptchaRefreshResponse } from '@/api/captcha'
import { ref, type Ref } from 'vue'
/**
* 验证码组合式函数
*/
export function useCaptcha() {
// 验证码数据
const captchaData: Ref<CaptchaResponse | null> = ref(null)
// 加载状态
const captchaLoading: Ref<boolean> = ref(false)
// 错误信息
const captchaError: Ref<string | null> = ref(null)
/**
* 生成验证码
*/
const handleGenerateCaptcha = async (): Promise<void> => {
try {
captchaLoading.value = true
captchaError.value = null
const response = await generateCaptcha()
captchaData.value = response
} catch (error) {
console.error('生成验证码失败:', error)
captchaError.value = error instanceof Error ? error.message : '生成验证码失败'
} finally {
captchaLoading.value = false
}
}
/**
* 刷新验证码
*/
const handleRefreshCaptcha = async (): Promise<void> => {
try {
captchaLoading.value = true
captchaError.value = null
// 检查是否有当前验证码ID
if (!captchaData.value?.captcha_id) {
console.warn('没有当前验证码ID将生成新的验证码')
await handleGenerateCaptcha()
return
}
const response = await refreshCaptcha(captchaData.value.captcha_id)
captchaData.value = response
} catch (error) {
console.error('刷新验证码失败:', error)
captchaError.value = error instanceof Error ? error.message : '刷新验证码失败'
} finally {
captchaLoading.value = false
}
}
return {
captchaData,
captchaLoading,
captchaError,
generateCaptcha: handleGenerateCaptcha,
refreshCaptcha: handleRefreshCaptcha
}
}
// 导出类型
export type { CaptchaResponse, CaptchaRefreshResponse }

View File

@@ -1,87 +0,0 @@
/**
* 环境变量检查工具
* 用于在开发环境中检查环境变量配置是否正确
*/
// 检查环境变量配置
export const checkEnvironmentVariables = () => {
console.log('🔧 环境变量检查')
// 在Vite中环境变量可能通过define选项直接定义
// 或者通过import.meta.env读取
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://hertzServer:8000/api'
const appTitle = import.meta.env.VITE_APP_TITLE || 'Hertz Admin'
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
// 检查必需的环境变量
const requiredVars = [
{ key: 'VITE_API_BASE_URL', value: apiBaseUrl },
{ key: 'VITE_APP_TITLE', value: appTitle },
{ key: 'VITE_APP_VERSION', value: appVersion },
]
requiredVars.forEach(({ key, value }) => {
if (value) {
console.log(`${key}: ${value}`)
} else {
console.warn(`${key}: 未设置`)
}
})
// 检查可选的环境变量
const devServerHost = import.meta.env.VITE_DEV_SERVER_HOST || 'localhost'
const devServerPort = import.meta.env.VITE_DEV_SERVER_PORT || '3000'
const optionalVars = [
{ key: 'VITE_DEV_SERVER_HOST', value: devServerHost },
{ key: 'VITE_DEV_SERVER_PORT', value: devServerPort },
]
optionalVars.forEach(({ key, value }) => {
if (value) {
console.log(` ${key}: ${value}`)
} else {
console.log(` ${key}: 未设置(使用默认值)`)
}
})
console.log('🎉 环境变量检查完成')
}
// 验证环境变量是否有效
export const validateEnvironment = () => {
// 检查API基础地址
if (!import.meta.env.VITE_API_BASE_URL) {
console.warn('⚠️ VITE_API_BASE_URL 未设置,将使用默认值')
}
// 检查应用配置
if (!import.meta.env.VITE_APP_TITLE) {
console.warn('⚠️ VITE_APP_TITLE 未设置,将使用默认值')
}
if (!import.meta.env.VITE_APP_VERSION) {
console.warn('⚠️ VITE_APP_VERSION 未设置,将使用默认值')
}
return {
isValid: true,
warnings: []
}
}
// 获取API基础地址
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
}
// 获取应用配置
export const getAppConfig = () => {
return {
title: import.meta.env.VITE_APP_TITLE || 'Hertz Admin',
version: import.meta.env.VITE_APP_VERSION || '1.0.0',
apiBaseUrl: getApiBaseUrl(),
devServerHost: import.meta.env.VITE_DEV_SERVER_HOST || 'localhost',
devServerPort: import.meta.env.VITE_DEV_SERVER_PORT || '3000',
}
}

View File

@@ -1,375 +0,0 @@
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
// 错误类型枚举
export enum ErrorType {
// 网络错误
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// 认证错误
UNAUTHORIZED = 'UNAUTHORIZED',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
TOKEN_INVALID = 'TOKEN_INVALID',
// 权限错误
FORBIDDEN = 'FORBIDDEN',
ACCESS_DENIED = 'ACCESS_DENIED',
// 业务错误
VALIDATION_ERROR = 'VALIDATION_ERROR',
BUSINESS_ERROR = 'BUSINESS_ERROR',
// 系统错误
SERVER_ERROR = 'SERVER_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
}
// 错误信息接口
export interface ErrorInfo {
code: number
message: string
type: ErrorType
details?: any
field?: string
}
// 错误处理器类
export class HertzErrorHandler {
private static instance: HertzErrorHandler
private i18n: any
constructor() {
// 在组件中使用时需要传入i18n实例
}
static getInstance(): HertzErrorHandler {
if (!HertzErrorHandler.instance) {
HertzErrorHandler.instance = new HertzErrorHandler()
}
return HertzErrorHandler.instance
}
// 设置i18n实例
setI18n(i18n: any) {
this.i18n = i18n
}
// 获取翻译文本
private t(key: string, fallback?: string): string {
if (this.i18n && this.i18n.t) {
return this.i18n.t(key)
}
return fallback || key
}
// 处理HTTP错误
handleHttpError(error: any): void {
const status = error?.response?.status
const data = error?.response?.data
console.error('🚨 HTTP错误详情:', {
status,
data,
url: error?.config?.url,
method: error?.config?.method,
requestData: error?.config?.data
})
switch (status) {
case 400:
this.handleBadRequestError(data)
break
case 401:
this.handleUnauthorizedError(data)
break
case 403:
this.handleForbiddenError(data)
break
case 404:
this.handleNotFoundError(data)
break
case 422:
this.handleValidationError(data)
break
case 429:
this.handleTooManyRequestsError(data)
break
case 500:
this.handleServerError(data)
break
case 502:
case 503:
case 504:
this.handleServiceUnavailableError(data)
break
default:
this.handleUnknownError(error)
}
}
// 处理400错误
private handleBadRequestError(data: any): void {
const message = data?.message || data?.detail || ''
// 检查是否是验证码相关错误
if (this.isMessageContains(message, ['验证码', 'captcha', 'Captcha'])) {
if (this.isMessageContains(message, ['过期', 'expired', 'expire'])) {
this.showError(this.t('error.captchaExpired', '验证码已过期,请刷新后重新输入'))
} else {
this.showError(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
}
return
}
// 检查是否是用户名或密码错误
if (this.isMessageContains(message, ['用户名', 'username', '密码', 'password', '登录', 'login'])) {
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
return
}
// 检查是否是注册相关错误
if (this.isMessageContains(message, ['用户名已存在', 'username exists', 'username already'])) {
this.showError(this.t('error.usernameExists', '用户名已存在,请选择其他用户名'))
return
}
if (this.isMessageContains(message, ['邮箱已注册', 'email exists', 'email already'])) {
this.showError(this.t('error.emailExists', '邮箱已被注册,请使用其他邮箱'))
return
}
if (this.isMessageContains(message, ['手机号已注册', 'phone exists', 'phone already'])) {
this.showError(this.t('error.phoneExists', '手机号已被注册,请使用其他手机号'))
return
}
// 默认400错误处理
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
}
// 处理401错误
private handleUnauthorizedError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['token', 'Token', '令牌', '过期', 'expired'])) {
this.showError(this.t('error.tokenExpired', '登录已过期,请重新登录'))
// 可以在这里添加自动跳转到登录页的逻辑
setTimeout(() => {
window.location.href = '/login'
}, 2000)
} else if (this.isMessageContains(message, ['账户锁定', 'account locked', 'locked'])) {
this.showError(this.t('error.accountLocked', '账户已被锁定,请联系管理员'))
} else if (this.isMessageContains(message, ['账户禁用', 'account disabled', 'disabled'])) {
this.showError(this.t('error.accountDisabled', '账户已被禁用,请联系管理员'))
} else {
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
}
}
// 处理403错误
private handleForbiddenError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['权限不足', 'permission denied', 'access denied'])) {
this.showError(this.t('error.permissionDenied', '权限不足,无法执行此操作'))
} else {
this.showError(this.t('error.accessDenied', '访问被拒绝,您没有执行此操作的权限'))
}
}
// 处理404错误
private handleNotFoundError(data: any): void {
const message = data?.message || data?.detail || ''
if (this.isMessageContains(message, ['用户', 'user'])) {
this.showError(this.t('error.userNotFound', '用户不存在或已被删除'))
} else if (this.isMessageContains(message, ['部门', 'department'])) {
this.showError(this.t('error.departmentNotFound', '部门不存在或已被删除'))
} else if (this.isMessageContains(message, ['角色', 'role'])) {
this.showError(this.t('error.roleNotFound', '角色不存在或已被删除'))
} else {
this.showError(this.t('error.404', '页面未找到'))
}
}
// 处理422验证错误
private handleValidationError(data: any): void {
console.log('🔍 422验证错误详情:', data)
// 处理FastAPI风格的验证错误
if (data?.detail && Array.isArray(data.detail)) {
const errors = data.detail
const errorMessages: string[] = []
errors.forEach((error: any) => {
const field = error.loc?.[error.loc.length - 1] || 'unknown'
const msg = error.msg || error.message || '验证失败'
// 根据字段和错误类型提供更具体的提示
if (field === 'username') {
if (msg.includes('required') || msg.includes('必填')) {
errorMessages.push(this.t('error.usernameRequired', '请输入用户名'))
} else if (msg.includes('length') || msg.includes('长度')) {
errorMessages.push('用户名长度不符合要求')
} else {
errorMessages.push(`用户名: ${msg}`)
}
} else if (field === 'password') {
if (msg.includes('required') || msg.includes('必填')) {
errorMessages.push(this.t('error.passwordRequired', '请输入密码'))
} else if (msg.includes('weak') || msg.includes('强度')) {
errorMessages.push(this.t('error.passwordTooWeak', '密码强度不足,请包含大小写字母、数字和特殊字符'))
} else {
errorMessages.push(`密码: ${msg}`)
}
} else if (field === 'email') {
if (msg.includes('format') || msg.includes('格式')) {
errorMessages.push(this.t('error.emailFormatError', '邮箱格式不正确,请输入有效的邮箱地址'))
} else {
errorMessages.push(`邮箱: ${msg}`)
}
} else if (field === 'phone') {
if (msg.includes('format') || msg.includes('格式')) {
errorMessages.push(this.t('error.phoneFormatError', '手机号格式不正确请输入11位手机号'))
} else {
errorMessages.push(`手机号: ${msg}`)
}
} else if (field === 'captcha' || field === 'captcha_code') {
errorMessages.push(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
} else {
errorMessages.push(`${field}: ${msg}`)
}
})
if (errorMessages.length > 0) {
this.showError(errorMessages.join(''))
return
}
}
// 处理其他格式的验证错误
if (data?.errors) {
const errors = data.errors
const errorMessages = []
for (const field in errors) {
if (errors[field] && Array.isArray(errors[field])) {
errorMessages.push(`${field}: ${errors[field].join(', ')}`)
} else if (errors[field]) {
errorMessages.push(`${field}: ${errors[field]}`)
}
}
if (errorMessages.length > 0) {
this.showError(`验证失败: ${errorMessages.join('; ')}`)
return
}
}
// 默认验证错误处理
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
}
// 处理429错误请求过多
private handleTooManyRequestsError(data: any): void {
this.showError(this.t('error.loginAttemptsExceeded', '登录尝试次数过多,账户已被临时锁定'))
}
// 处理500错误
private handleServerError(data: any): void {
this.showError(this.t('error.500', '服务器内部错误,请稍后重试'))
}
// 处理服务不可用错误
private handleServiceUnavailableError(data: any): void {
this.showError(this.t('error.serviceUnavailable', '服务暂时不可用,请稍后重试'))
}
// 处理网络错误
handleNetworkError(error: any): void {
if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
} else if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
this.showError(this.t('error.timeout', '请求超时,请稍后重试'))
} else {
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
}
}
// 处理未知错误
private handleUnknownError(error: any): void {
console.error('🚨 未知错误:', error)
this.showError(this.t('error.operationFailed', '操作失败,请稍后重试'))
}
// 显示错误消息
private showError(msg: string): void {
message.error(msg)
}
// 显示成功消息
showSuccess(msg: string): void {
message.success(msg)
}
// 显示警告消息
showWarning(msg: string): void {
message.warning(msg)
}
// 检查消息是否包含指定关键词
private isMessageContains(message: string, keywords: string[]): boolean {
if (!message) return false
const lowerMessage = message.toLowerCase()
return keywords.some(keyword => lowerMessage.includes(keyword.toLowerCase()))
}
// 处理业务操作成功
handleSuccess(operation: string, customMessage?: string): void {
if (customMessage) {
this.showSuccess(customMessage)
return
}
switch (operation) {
case 'save':
this.showSuccess(this.t('error.saveSuccess', '保存成功'))
break
case 'delete':
this.showSuccess(this.t('error.deleteSuccess', '删除成功'))
break
case 'update':
this.showSuccess(this.t('error.updateSuccess', '更新成功'))
break
case 'create':
this.showSuccess('创建成功')
break
case 'login':
this.showSuccess('登录成功')
break
case 'register':
this.showSuccess('注册成功')
break
default:
this.showSuccess('操作成功')
}
}
}
// 导出单例实例
export const errorHandler = HertzErrorHandler.getInstance()
// 导出便捷方法
export const handleError = (error: any) => {
if (error?.response) {
errorHandler.handleHttpError(error)
} else if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
errorHandler.handleNetworkError(error)
} else {
console.error('🚨 处理错误:', error)
errorHandler.showError('操作失败,请稍后重试')
}
}
export const handleSuccess = (operation: string, customMessage?: string) => {
errorHandler.handleSuccess(operation, customMessage)
}

View File

@@ -1,154 +0,0 @@
/**
* 权限管理工具类
* 统一管理用户权限检查和菜单过滤逻辑
*/
import { computed } from 'vue'
import { useUserStore } from '@/stores/hertz_user'
import { UserRole } from '@/router/admin_menu'
// 权限检查接口
export interface PermissionChecker {
hasRole(role: string): boolean
hasPermission(permission: string): boolean
hasAnyRole(roles: string[]): boolean
hasAnyPermission(permissions: string[]): boolean
isAdmin(): boolean
isLoggedIn(): boolean
}
// 权限管理类
export class PermissionManager implements PermissionChecker {
// 延迟获取 Pinia store避免在 Pinia 未初始化时调用
private get userStore() {
return useUserStore()
}
/**
* 检查用户是否拥有指定角色
*/
hasRole(role: string): boolean {
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
return userRoles.includes(role)
}
/**
* 检查用户是否拥有指定权限
*/
hasPermission(permission: string): boolean {
const userPermissions = this.userStore.userInfo?.permissions || []
return userPermissions.includes(permission)
}
/**
* 检查用户是否拥有任意一个指定角色
*/
hasAnyRole(roles: string[]): boolean {
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
return roles.some(role => userRoles.includes(role))
}
/**
* 检查用户是否拥有任意一个指定权限
*/
hasAnyPermission(permissions: string[]): boolean {
const userPermissions = this.userStore.userInfo?.permissions || []
return permissions.some(permission => userPermissions.includes(permission))
}
/**
* 检查用户是否为管理员
*/
isAdmin(): boolean {
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN]
return this.hasAnyRole(adminRoles)
}
/**
* 检查用户是否已登录
*/
isLoggedIn(): boolean {
return this.userStore.isLoggedIn && !!this.userStore.userInfo
}
/**
* 获取用户角色列表
*/
getUserRoles(): string[] {
return this.userStore.userInfo?.roles?.map(r => r.role_code) || []
}
/**
* 获取用户权限列表
*/
getUserPermissions(): string[] {
return this.userStore.userInfo?.permissions || []
}
/**
* 检查用户是否可以访问指定路径
*/
canAccessPath(path: string, requiredRoles?: string[], requiredPermissions?: string[]): boolean {
if (!this.isLoggedIn()) {
return false
}
// 如果没有指定权限要求,默认允许访问
if (!requiredRoles && !requiredPermissions) {
return true
}
// 检查角色权限
if (requiredRoles && requiredRoles.length > 0) {
if (!this.hasAnyRole(requiredRoles)) {
return false
}
}
// 检查具体权限
if (requiredPermissions && requiredPermissions.length > 0) {
if (!this.hasAnyPermission(requiredPermissions)) {
return false
}
}
return true
}
}
// 创建全局权限管理实例
export const permissionManager = new PermissionManager()
// 便捷的权限检查函数
export const usePermission = () => {
return {
hasRole: (role: string) => permissionManager.hasRole(role),
hasPermission: (permission: string) => permissionManager.hasPermission(permission),
hasAnyRole: (roles: string[]) => permissionManager.hasAnyRole(roles),
hasAnyPermission: (permissions: string[]) => permissionManager.hasAnyPermission(permissions),
isAdmin: () => permissionManager.isAdmin(),
isLoggedIn: () => permissionManager.isLoggedIn(),
canAccessPath: (path: string, requiredRoles?: string[], requiredPermissions?: string[]) =>
permissionManager.canAccessPath(path, requiredRoles, requiredPermissions)
}
}
// Vue 3 组合式 API 权限检查 Hook
export const usePermissionCheck = () => {
const userStore = useUserStore()
return {
// 响应式权限检查
hasRole: (role: string) => computed(() => permissionManager.hasRole(role)),
hasPermission: (permission: string) => computed(() => permissionManager.hasPermission(permission)),
hasAnyRole: (roles: string[]) => computed(() => permissionManager.hasAnyRole(roles)),
hasAnyPermission: (permissions: string[]) => computed(() => permissionManager.hasAnyPermission(permissions)),
isAdmin: computed(() => permissionManager.isAdmin()),
isLoggedIn: computed(() => permissionManager.isLoggedIn()),
// 用户信息
userRoles: computed(() => permissionManager.getUserRoles()),
userPermissions: computed(() => permissionManager.getUserPermissions()),
userInfo: computed(() => userStore.userInfo)
}
}

View File

@@ -1,201 +0,0 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { handleError } from './hertz_error_handler'
// 请求配置接口
interface RequestConfig extends AxiosRequestConfig {
showLoading?: boolean
showError?: boolean
metadata?: {
requestId: string
timestamp: string
}
}
// 响应数据接口
interface ApiResponse<T = any> {
code: number
message: string
data: T
success?: boolean
}
// 请求拦截器配置
const requestInterceptor = {
onFulfilled: (config: RequestConfig) => {
const timestamp = new Date().toISOString()
const requestId = Math.random().toString(36).substr(2, 9)
// 简化日志,只在开发环境显示关键信息
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`)
}
// 添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
// 如果是FormData删除Content-Type让浏览器自动设置
if (config.data instanceof FormData) {
if (config.headers && 'Content-Type' in config.headers) {
delete config.headers['Content-Type']
}
console.log('📦 检测到FormData移除Content-Type让浏览器自动设置')
}
// 显示loading
if (config.showLoading !== false) {
// 这里可以添加loading显示逻辑
}
// 将requestId添加到config中用于响应时匹配
config.metadata = { requestId, timestamp }
return config as InternalAxiosRequestConfig
},
onRejected: (error: any) => {
console.error('❌ 请求错误:', error.message)
return Promise.reject(error)
}
}
// 响应拦截器配置
const responseInterceptor = {
onFulfilled: (response: AxiosResponse) => {
const requestTimestamp = (response.config as any).metadata?.timestamp
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
// 简化日志,只在开发环境显示关键信息
if (import.meta.env.DEV) {
console.log(`${response.status} ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`)
}
// 统一处理响应数据
if (response.data && typeof response.data === 'object') {
// 如果后端返回的是标准格式 {code, message, data}
if ('code' in response.data) {
// 标准API响应格式处理
}
}
return response
},
onRejected: (error: any) => {
const requestTimestamp = (error.config as any)?.metadata?.timestamp
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
// 简化错误日志
console.error(`${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`)
console.error('错误信息:', error.response?.data?.message || error.message)
// 使用统一错误处理器(支持按请求关闭全局错误提示)
const showError = (error.config as any)?.showError
if (showError !== false) {
handleError(error)
}
// 特殊处理401错误
if (error.response?.status === 401) {
console.warn('🔒 未授权清除token')
localStorage.removeItem('token')
// 可以在这里跳转到登录页
}
return Promise.reject(error)
}
}
class HertzRequest {
private instance: AxiosInstance
constructor(config: AxiosRequestConfig) {
// 在开发环境中使用空字符串以便Vite代理正常工作
// 在生产环境中使用完整的API地址
const isDev = import.meta.env.DEV
const baseURL = isDev ? '' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
console.log('🔧 创建axios实例 - isDev:', isDev)
console.log('🔧 创建axios实例 - baseURL:', baseURL)
console.log('🔧 环境变量 VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
this.instance = axios.create({
baseURL,
timeout: 10000,
// 不设置默认Content-Type让每个请求根据数据类型自动设置
...config
})
// 添加请求拦截器
this.instance.interceptors.request.use(
requestInterceptor.onFulfilled,
requestInterceptor.onRejected
)
// 添加响应拦截器
this.instance.interceptors.response.use(
responseInterceptor.onFulfilled,
responseInterceptor.onRejected
)
}
// GET请求
get<T = any>(url: string, config?: RequestConfig): Promise<T> {
return this.instance.get(url, config).then(res => res.data)
}
// POST请求
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
// 如果不是FormData设置Content-Type为application/json
const finalConfig = { ...config }
if (!(data instanceof FormData)) {
finalConfig.headers = {
'Content-Type': 'application/json',
...finalConfig.headers
}
}
return this.instance.post(url, data, finalConfig).then(res => res.data)
}
// PUT请求
put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
// 如果不是FormData设置Content-Type为application/json
const finalConfig = { ...config }
if (!(data instanceof FormData)) {
finalConfig.headers = {
'Content-Type': 'application/json',
...finalConfig.headers
}
}
return this.instance.put(url, data, finalConfig).then(res => res.data)
}
// DELETE请求
delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
return this.instance.delete(url, config).then(res => res.data)
}
// PATCH请求
patch<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.instance.patch(url, data, config).then(res => res.data)
}
// 上传文件
upload<T = any>(url: string, formData: FormData, config?: RequestConfig): Promise<T> {
// 不要手动设置Content-Type让浏览器自动设置这样会包含正确的boundary
return this.instance.post(url, formData, {
...config,
headers: {
// 不设置Content-Type让浏览器自动设置multipart/form-data的header
...config?.headers
}
}).then(res => res.data)
}
}
// 创建默认实例
export const request = new HertzRequest({})
// 导出类和配置接口
export { HertzRequest }
export type { RequestConfig, ApiResponse }

View File

@@ -1,138 +0,0 @@
/**
* 路由工具函数
* 用于动态路由相关的辅助功能
*/
// 获取views目录下的所有Vue文件
export const getViewFiles = () => {
const viewsContext = import.meta.glob('@/views/*.vue')
return Object.keys(viewsContext).map(path => path.split('/').pop())
}
// 从文件名生成路由名称
export const generateRouteName = (fileName: string): string => {
return fileName.replace('.vue', '')
}
// 从文件名生成路由路径
export const generateRoutePath = (fileName: string): string => {
const routeName = generateRouteName(fileName)
let routePath = `/${routeName.toLowerCase()}`
// 处理特殊命名(驼峰转短横线)
if (routeName !== routeName.toLowerCase()) {
routePath = `/${routeName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`
}
return routePath
}
// 生成路由标题
export const generateRouteTitle = (routeName: string): string => {
const titleMap: Record<string, string> = {
Dashboard: '仪表板',
User: '用户管理',
Profile: '个人资料',
Settings: '系统设置',
Test: '样式测试',
WebSocketTest: 'WebSocket测试',
NotFound: '页面未找到',
}
return titleMap[routeName] || routeName
}
// 判断路由是否需要认证
export const shouldRequireAuth = (routeName: string): boolean => {
const publicRoutes = ['Test', 'WebSocketTest']
return !(
publicRoutes.includes(routeName) || // 公开路由列表
routeName.startsWith('Demo') // Demo开头的页面不需要认证
)
}
// 获取公开路由列表
export const getPublicRoutes = (): string[] => {
return ['Test', 'WebSocketTest', 'Demo'] // 可以添加更多公开路由
}
// 打印路由调试信息
export const debugRoutes = () => {
const viewFiles = getViewFiles()
const fixedFiles = ['Home.vue', 'Login.vue']
const dynamicFiles = viewFiles.filter(file => !fixedFiles.includes(file) && file !== 'NotFound.vue')
console.log('🔍 路由调试信息:')
console.log('📁 所有视图文件:', viewFiles)
console.log('🔒 固定路由文件:', fixedFiles)
console.log('🚀 动态路由文件:', dynamicFiles)
const publicRoutes = getPublicRoutes()
console.log('🔓 公开路由 (不需要认证):', publicRoutes)
console.log('\n📋 动态路由配置:')
dynamicFiles.forEach(file => {
const routeName = generateRouteName(file)
const routePath = generateRoutePath(file)
const title = generateRouteTitle(routeName)
const requiresAuth = shouldRequireAuth(routeName)
const isPublic = !requiresAuth
console.log(` ${file}${routePath} (${title}) ${isPublic ? '🔓' : '🔒'}`)
})
console.log('\n🎯 Demo页面特殊说明:')
console.log(' - Demo开头的页面不需要认证 (Demo.vue, DemoPage.vue等)')
console.log(' - 可以直接访问 /demo 路径')
}
// 在开发环境中自动调用调试函数
if (import.meta.env.DEV) {
debugRoutes()
}
// 提供全局访问的路由信息查看函数
export const showRoutesInfo = () => {
console.log('🚀 Hertz Admin 路由配置信息:')
console.log('📋 完整路由列表:')
// 注意: 这里需要从路由实例中获取真实数据
// 由于路由工具函数在路由配置之前加载,这里提供的是示例数据
// 实际的动态路由信息会在项目启动时通过logRouteInfo()函数显示
console.log('\n🔒 固定路由 (需要手动配置):')
console.log(' 🔒 / → Home (首页)')
console.log(' 🔓 /login → Login (登录)')
console.log('\n🚀 动态路由 (自动生成):')
console.log(' 🔒 /dashboard → Dashboard (仪表板)')
console.log(' 🔒 /user → User (用户管理)')
console.log(' 🔒 /profile → Profile (个人资料)')
console.log(' 🔒 /settings → Settings (系统设置)')
console.log(' 🔓 /test → Test (样式测试)')
console.log(' 🔓 /websocket-test → WebSocketTest (WebSocket测试)')
console.log(' 🔓 /demo → Demo (动态路由演示)')
console.log('\n❓ 404路由:')
console.log(' ❓ /:pathMatch(.*)* → NotFound (页面未找到)')
console.log('\n📖 访问说明:')
console.log(' 🔓 公开路由: 可以直接访问,不需要登录')
console.log(' 🔒 私有路由: 需要登录后才能访问')
console.log(' 💡 提示: 可以在浏览器中直接访问这些路径')
console.log('\n🌐 可用链接:')
console.log(' http://localhost:3000/ - 首页 (需要登录)')
console.log(' http://localhost:3000/login - 登录页面')
console.log(' http://localhost:3000/dashboard - 仪表板 (需要登录)')
console.log(' http://localhost:3000/user - 用户管理 (需要登录)')
console.log(' http://localhost:3000/profile - 个人资料 (需要登录)')
console.log(' http://localhost:3000/settings - 系统设置 (需要登录)')
console.log(' http://localhost:3000/test - 样式测试 (公开)')
console.log(' http://localhost:3000/websocket-test - WebSocket测试 (公开)')
console.log(' http://localhost:3000/demo - 动态路由演示 (公开)')
console.log(' http://localhost:3000/any-other-path - 404页面 (公开)')
console.log('\n✅ 路由配置加载完成!')
console.log('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
}

View File

@@ -1,113 +0,0 @@
/**
* URL处理工具函数
*/
/**
* 获取完整的文件URL
* @param relativePath 相对路径,如 /media/detection/original/xxx.jpg
* @returns 完整的URL
*/
export function getFullFileUrl(relativePath: string): string {
if (!relativePath) {
console.warn('⚠️ 文件路径为空')
return ''
}
// 如果已经是完整URL直接返回
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
return relativePath
}
// 在开发环境中使用相对路径通过Vite代理
if (import.meta.env.DEV) {
return relativePath
}
// 在生产环境中拼接完整的URL
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
return `${baseURL}${relativePath}`
}
/**
* 获取API基础URL
* @returns API基础URL
*/
export function getApiBaseUrl(): string {
if (import.meta.env.DEV) {
return '' // 开发环境使用空字符串通过Vite代理
}
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
}
/**
* 获取媒体文件基础URL
* @returns 媒体文件基础URL
*/
export function getMediaBaseUrl(): string {
if (import.meta.env.DEV) {
return '' // 开发环境使用空字符串通过Vite代理
}
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
return baseURL.replace('/api', '') // 移除/api后缀
}
/**
* 检查URL是否可访问
* @param url 要检查的URL
* @returns Promise<boolean>
*/
export async function checkUrlAccessibility(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch (error) {
console.error('❌ URL访问检查失败:', url, error)
return false
}
}
/**
* 格式化文件大小
* @param bytes 字节数
* @returns 格式化后的文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 获取文件扩展名
* @param filename 文件名
* @returns 文件扩展名
*/
export function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || ''
}
/**
* 检查是否为图片文件
* @param filename 文件名或URL
* @returns 是否为图片文件
*/
export function isImageFile(filename: string): boolean {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const extension = getFileExtension(filename)
return imageExtensions.includes(extension)
}
/**
* 检查是否为视频文件
* @param filename 文件名或URL
* @returns 是否为视频文件
*/
export function isVideoFile(filename: string): boolean {
const videoExtensions = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv']
const extension = getFileExtension(filename)
return videoExtensions.includes(extension)
}

View File

@@ -1,251 +0,0 @@
import { useAppStore } from '@/stores/hertz_app'
// 日期格式化
export const formatDate = (date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss') => {
const d = new Date(date)
if (isNaN(d.getTime())) {
return ''
}
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
// 防抖函数
export const debounce = <T extends (...args: any[]) => any>(
func: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func(...args), delay)
}
}
// 节流函数
export const throttle = <T extends (...args: any[]) => any>(
func: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let lastCall = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
func(...args)
}
}
}
// 深拷贝
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T
}
if (typeof obj === 'object') {
const cloned = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
return obj
}
// 数组去重
export const unique = <T>(arr: T[]): T[] => {
return Array.from(new Set(arr))
}
// 获取URL参数
export const getUrlParam = (name: string, url?: string): string | null => {
const searchUrl = url || window.location.search
const params = new URLSearchParams(searchUrl)
return params.get(name)
}
// 设置URL参数
export const setUrlParam = (name: string, value: string, url?: string): string => {
const searchUrl = url || window.location.search
const params = new URLSearchParams(searchUrl)
if (value === null || value === undefined || value === '') {
params.delete(name)
} else {
params.set(name, value)
}
return params.toString()
}
// 复制到剪贴板
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
// 降级处理
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
textArea.remove()
if (!successful) {
throw new Error('复制失败')
}
}
return true
} catch (error) {
console.error('复制失败:', error)
return false
}
}
// 下载文件
export const downloadFile = (url: string, filename?: string) => {
const link = document.createElement('a')
link.href = url
link.download = filename || ''
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 格式化文件大小
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 验证邮箱格式
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 验证手机号格式(中国大陆)
export const isValidPhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// 验证身份证号
export const isValidIdCard = (idCard: string): boolean => {
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return idCardRegex.test(idCard)
}
// 生成随机字符串
export const generateRandomString = (length: number = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// 等待函数
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 获取浏览器信息
export const getBrowserInfo = () => {
const userAgent = navigator.userAgent
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)
const isFirefox = /Firefox/.test(userAgent)
const isSafari = /Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor)
const isEdge = /Edg/.test(userAgent)
const isIE = /MSIE|Trident/.test(userAgent)
return {
isChrome,
isFirefox,
isSafari,
isEdge,
isIE,
userAgent,
}
}
// 本地存储封装
export const storage = {
get: <T>(key: string, defaultValue?: T): T | null => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : (defaultValue ?? null)
} catch (error) {
console.error(`获取本地存储失败 (${key}):`, error)
return defaultValue ?? null
}
},
set: <T>(key: string, value: T): void => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error(`设置本地存储失败 (${key}):`, error)
}
},
remove: (key: string): void => {
try {
localStorage.removeItem(key)
} catch (error) {
console.error(`删除本地存储失败 (${key}):`, error)
}
},
clear: (): void => {
try {
localStorage.clear()
} catch (error) {
console.error('清空本地存储失败:', error)
}
},
}

View File

@@ -1,112 +0,0 @@
import { menuApi, type Menu } from '@/api/menu'
// 菜单key和菜单ID的映射关系
let menuKeyToIdMap: Map<string, number> = new Map()
let menuIdToKeyMap: Map<number, string> = new Map()
let isInitialized = false
// 菜单key和菜单code的映射关系用于建立映射
const MENU_KEY_TO_CODE_MAP: { [key: string]: string } = {
'dashboard': 'dashboard',
'user-management': 'user_management',
'department-management': 'department_management',
'menu-management': 'menu_management',
'teacher': 'role_management'
}
/**
* 初始化菜单映射
*/
export const initializeMenuMapping = async (): Promise<void> => {
try {
// 获取菜单树数据
const response = await menuApi.getMenuTree()
if (response.code === 200 && response.data) {
// 清空现有映射
menuKeyToIdMap.clear()
// 递归处理菜单树
const processMenuTree = (menus: Menu[]) => {
menus.forEach(menu => {
if (menu.key && menu.id) {
menuKeyToIdMap.set(menu.key, menu.id)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
processMenuTree(menu.children)
}
})
}
processMenuTree(response.data)
}
} catch (error) {
console.error('初始化菜单映射时发生错误:', error)
}
}
/**
* 递归构建菜单映射关系
*/
const buildMenuMapping = (menus: Menu[]): void => {
menus.forEach(menu => {
// 根据menu_code找到对应的key
const menuKey = Object.keys(MENU_KEY_TO_CODE_MAP).find(
key => MENU_KEY_TO_CODE_MAP[key] === menu.menu_code
)
if (menuKey) {
menuKeyToIdMap.set(menuKey, menu.menu_id)
menuIdToKeyMap.set(menu.menu_id, menuKey)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
buildMenuMapping(menu.children)
}
})
}
/**
* 根据菜单key获取菜单ID
*/
export const getMenuIdByKey = (menuKey: string): number | undefined => {
return menuKeyToIdMap.get(menuKey)
}
/**
* 根据菜单ID获取菜单key
*/
export const getMenuKeyById = (menuId: number): string | undefined => {
return menuIdToKeyMap.get(menuId)
}
/**
* 检查用户是否有指定菜单的权限
*/
export const hasMenuPermissionById = (menuKey: string, userMenuPermissions: number[]): boolean => {
const menuId = getMenuIdByKey(menuKey)
if (!menuId) {
// 降级策略:如果没有找到菜单映射,则允许显示(向后兼容)
return true
}
return userMenuPermissions.includes(menuId)
}
/**
* 获取用户有权限的菜单keys
*/
export const getPermittedMenuKeys = (userMenuPermissions: number[]): string[] => {
const permittedKeys: string[] = []
userMenuPermissions.forEach(menuId => {
const menuKey = getMenuKeyById(menuId)
if (menuKey) {
permittedKeys.push(menuKey)
}
})
return permittedKeys
}

View File

@@ -1,730 +0,0 @@
// 前端ONNX YOLO检测工具类
import * as ort from 'onnxruntime-web'
// ONNX检测结果接口
export interface YOLODetectionResult {
detections: Array<{
class_name: string
confidence: number
bbox: {
x: number
y: number
width: number
height: number
}
}>
object_count: number
detected_categories: string[]
confidence_scores: number[]
avg_confidence: number
annotated_image: string // base64图像
processing_time: number
}
// 不预置任何类别名称;等待后端或标签文件提供
class YOLODetector {
private session: ort.InferenceSession | null = null
private modelPath: string = ''
private classNames: string[] = []
private inputShape: [number, number] = [640, 640] // 默认输入尺寸(可在 WASM 下动态调小)
private currentEP: 'webgpu' | 'webgl' | 'wasm' = 'wasm'
/**
* 加载ONNX模型
* @param modelPath 模型路径相对于public目录
* @param classNames 类别名称列表可选如果不提供则使用默认COCO类别
*/
async loadModel(modelPath: string, classNames?: string[], forceEP?: 'webgpu' | 'webgl' | 'wasm'): Promise<void> {
try {
console.log('🔄 开始加载ONNX模型:', modelPath)
// 设置类别名称
if (classNames && classNames.length > 0) {
this.classNames = classNames
console.log('📦 使用自定义类别:', classNames.length, '个类别')
} else {
// 如果未提供类别,稍后根据输出维度自动推断数量并用 class_0.. 命名
this.classNames = []
console.log('📦 未提供类别,将根据模型输出自动推断类别数量')
}
// 动态选择可用的 wasm 资源路径,避免 404/HTML 导致的“magic word”错误
const ensureWasmPath = async () => {
const candidates = [
'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/',
'https://unpkg.com/onnxruntime-web@1.23.2/dist/',
'/onnxruntime-web/', // 如果你把 dist 拷贝到 public/onnxruntime-web/
'/ort/' // 或者 public/ort/
]
for (const base of candidates) {
try {
const testUrl = base.replace(/\/$/, '') + '/ort-wasm.wasm'
const res = await fetch(testUrl, { method: 'HEAD', cache: 'no-store', mode: 'no-cors' as any })
// no-cors 模式下 status 为 0也视为可用跨域但可下载
if (res.ok || res.status === 0) {
// @ts-ignore
ort.env.wasm.wasmPaths = base
return true
}
} catch {}
}
return false
}
await ensureWasmPath()
// 配置 WASM 线程:若不支持跨域隔离/SharedArrayBuffer则退回单线程避免“worker not ready”
const canMultiThread = (self as any).crossOriginIsolated && typeof (self as any).SharedArrayBuffer !== 'undefined'
try {
// @ts-ignore
ort.env.wasm.numThreads = canMultiThread ? Math.max(2, Math.min(4, (navigator as any)?.hardwareConcurrency || 2)) : 1
// @ts-ignore
ort.env.wasm.proxy = true
} catch {}
const createWithEP = async (ep: 'webgpu' | 'webgl' | 'wasm') => {
if (ep === 'webgpu') {
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['webgpu'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'webgpu'
return
}
if (ep === 'webgl') {
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['webgl'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'webgl'
return
}
// wasm
const prefer: ort.InferenceSession.SessionOptions = {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
}
this.session = await ort.InferenceSession.create(modelPath, prefer)
this.currentEP = 'wasm'
}
// 配置 ONNX Runtime优先 GPUWebGPU/WebGL再回退 WASM
// 支持通过 localStorage 开关强制使用 WASMlocalStorage.setItem('ort_force_wasm','1')
// 也可通过第三个参数 forceEP 指定(用于错误时的程序化降级)
const forceWasm = forceEP === 'wasm' || (localStorage.getItem('ort_force_wasm') === '1')
// 1) WebGPU实验性浏览器需支持 navigator.gpu
let created = false
if (!forceWasm && (navigator as any)?.gpu && (!forceEP || forceEP === 'webgpu')) {
try {
// 动态引入 webgpu 版本(若不支持不会打包)
await import('onnxruntime-web/webgpu')
await createWithEP('webgpu')
created = true
console.log('✅ 使用 WebGPU 推理')
} catch (e) {
console.warn('⚠️ WebGPU 初始化失败,回退到 WebGL:', e)
}
}
// 2) WebGLGPU 加速,兼容更好)
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
try {
await createWithEP('webgl')
created = true
console.log('✅ 使用 WebGL 推理')
} catch (e2) {
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
}
}
// 3) WASMCPU
if (!created) {
try {
// 设置 WASM 线程/特性(路径已在 ensureWasmPath 中选择)
// @ts-ignore
ort.env.wasm.numThreads = Math.max(1, Math.min(4, (navigator as any)?.hardwareConcurrency || 2))
// @ts-ignore
ort.env.wasm.proxy = true
} catch {}
await createWithEP('wasm')
console.log('✅ 使用 WASM 推理')
}
this.modelPath = modelPath
// 根据后端动态调整输入尺寸WASM 默认调小以提升流畅度,可用 localStorage 覆盖
try {
const override = parseInt(localStorage.getItem('ort_input_size') || '', 10)
if (Number.isFinite(override) && override >= 256 && override <= 1024) {
this.inputShape = [override, override] as any
} else if (this.currentEP === 'wasm') {
this.inputShape = [512, 512] as any
} else {
this.inputShape = [640, 640] as any
}
console.log('🧩 推理输入尺寸:', this.inputShape[0])
} catch {}
// 获取模型输入输出信息(兼容性更强的写法)
const inputNames = this.session.inputNames
const outputNames = this.session.outputNames
console.log('✅ 模型加载成功')
console.log('📥 输入:', inputNames)
console.log('📤 输出:', outputNames)
// 尝试从 outputMetadata 推断类别数(某些环境不提供 dims需要兜底
try {
if (outputNames && outputNames.length > 0) {
const outputMetadata: any = (this.session as any).outputMetadata
const outputName = outputNames[0]
const meta = outputMetadata?.[outputName]
const outputShape: number[] | undefined = meta?.dims
if (Array.isArray(outputShape) && outputShape.length >= 3) {
const numClasses = (outputShape[2] as number) - 5 // YOLO: [N, B, 5+C]
if (Number.isFinite(numClasses) && numClasses > 0 && numClasses !== this.classNames.length) {
console.warn(`⚠️ 模型输出类别数 (${numClasses}) 与提供的类别数 (${this.classNames.length}) 不匹配/或未提供`)
if (this.classNames.length === 0) {
this.classNames = Array.from({ length: numClasses }, (_, i) => `class_${i}`)
console.log('📦 根据模型输出调整类别数量为:', numClasses)
}
}
} else {
console.warn('⚠️ 无法从 outputMetadata 推断输出维度将在首次推理时根据输出tensor推断。')
}
}
} catch (metaErr) {
console.warn('⚠️ 读取 outputMetadata 失败,将在首次推理时推断类别数。', metaErr)
}
} catch (error) {
console.error('❌ 加载模型失败:', error)
throw new Error(`加载ONNX模型失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 检查模型是否已加载
*/
isLoaded(): boolean {
return this.session !== null
}
/**
* 获取当前加载的模型路径
*/
getModelPath(): string {
return this.modelPath
}
/**
* 获取类别名称列表
*/
getClassNames(): string[] {
return this.classNames
}
/**
* 预处理图像Ultralytics letterbox保比例缩放+灰边填充)
* 返回输入张量与还原坐标所需的比例与padding
*/
private preprocessImage(image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement): {
input: Float32Array
ratio: number
padX: number
padY: number
dstW: number
dstH: number
srcW: number
srcH: number
} {
const dstW = this.inputShape[0]
const dstH = this.inputShape[1]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建canvas上下文')
const srcW = image instanceof HTMLVideoElement ? image.videoWidth : (image as HTMLImageElement | HTMLCanvasElement).width
const srcH = image instanceof HTMLVideoElement ? image.videoHeight : (image as HTMLImageElement | HTMLCanvasElement).height
// 计算 letterbox
const r = Math.min(dstW / srcW, dstH / srcH)
const newW = Math.round(srcW * r)
const newH = Math.round(srcH * r)
const padX = Math.floor((dstW - newW) / 2)
const padY = Math.floor((dstH - newH) / 2)
canvas.width = dstW
canvas.height = dstH
// 背景填充灰色(114)与 Ultralytics 一致
ctx.fillStyle = 'rgb(114,114,114)'
ctx.fillRect(0, 0, dstW, dstH)
// 绘制等比缩放后的图像到中间
ctx.drawImage(image as any, 0, 0, srcW, srcH, padX, padY, newW, newH)
const imageData = ctx.getImageData(0, 0, dstW, dstH)
const data = imageData.data
const input = new Float32Array(3 * dstW * dstH)
for (let i = 0; i < data.length; i += 4) {
const r8 = data[i] / 255.0
const g8 = data[i + 1] / 255.0
const b8 = data[i + 2] / 255.0
const idx = i / 4
input[idx] = r8
input[idx + dstW * dstH] = g8
input[idx + dstW * dstH * 2] = b8
}
return { input, ratio: r, padX, padY, dstW, dstH, srcW, srcH }
}
/**
* 非极大值抑制NMS
*/
private nms(boxes: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}>, iouThreshold: number): number[] {
if (boxes.length === 0) return []
// 按置信度排序
boxes.sort((a, b) => b.conf - a.conf)
const selected: number[] = []
const suppressed = new Set<number>()
for (let i = 0; i <boxes.length; i++) {
if (suppressed.has(i)) continue
selected.push(i)
const box1 = boxes[i]
for (let j = i + 1; j < boxes.length; j++) {
if (suppressed.has(j)) continue
const box2 = boxes[j]
// 计算IoU
const iou = this.calculateIoU(box1, box2)
if (iou > iouThreshold) {
suppressed.add(j)
}
}
}
return selected
}
/**
* 计算IoU交并比
*/
private calculateIoU(box1: {x: number, y: number, w: number, h: number}, box2: {x: number, y: number, w: number, h: number}): number {
const x1 = Math.max(box1.x, box2.x)
const y1 = Math.max(box1.y, box2.y)
const x2 = Math.min(box1.x + box1.w, box2.x + box2.w)
const y2 = Math.min(box1.y + box1.h, box2.y + box2.h)
if (x2 < x1 || y2 < y1) return 0
const intersection = (x2 - x1) * (y2 - y1)
const area1 = box1.w * box1.h
const area2 = box2.w * box2.h
const union = area1 + area2 - intersection
return intersection / union
}
/**
* 后处理检测结果
*/
private postprocess(
output: ort.Tensor,
meta: { ratio: number; padX: number; padY: number; srcW: number; srcH: number },
confThreshold: number,
nmsThreshold: number,
opts?: { maxDetections?: number; minBoxArea?: number; classWise?: boolean }
): Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}> {
const outputData = output.data as Float32Array
const outputShape = output.dims || []
// YOLO输出常见两种
// A) [1, num_boxes, 5+num_classes]
// B) [1, 5+num_classes, num_boxes]
// 另外也可能已经扁平化为 [num_boxes, 5+num_classes]
let numBoxes = 0
let numFeatures = 0
if (outputShape.length === 3) {
// 取更大的作为 boxes 维度(通常是 8400较小的是 5+C通常是 85
const a = outputShape[1] as number
const b = outputShape[2] as number
if (a >= b) {
numBoxes = a
numFeatures = b
} else {
numBoxes = b
numFeatures = a
}
} else if (outputShape.length === 2) {
numBoxes = outputShape[0] as number
numFeatures = outputShape[1] as number
} else {
// 无维度信息时根据长度推断(保底)
numFeatures = 85
numBoxes = Math.floor(outputData.length / numFeatures)
}
const numClasses = Math.max(0, numFeatures - 5)
const detections: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
// 还原到原图坐标:先减去 padding再除以 ratio
const { ratio, padX, padY, srcW: originalWidth, srcH: originalHeight } = meta
// 获取 (row i, col j) 的值,兼容布局 A/B
const getVal = (i: number, j: number): number => {
if (outputShape.length === 3) {
const a = outputShape[1] as number
const b = outputShape[2] as number
if (a >= b) {
// [1, boxes, features]
return outputData[i * b + j]
}
// [1, features, boxes]
return outputData[j * a + i]
}
// [boxes, features]
return outputData[i * numFeatures + j]
}
// sigmoid
const sigmoid = (v: number) => 1 / (1 + Math.exp(-v))
// 情况一部分导出的ONNX已经做过NMS输出形如 [num, 6]x1,y1,x2,y2,score,classId或其它顺序
const tryPostNmsLayouts = () => {
const candidates: Array<(row: (j:number)=>number) => {x:number,y:number,w:number,h:number,conf:number,cls:number} | null> = [
// [x1,y1,x2,y2,score,cls]
(get) => {
const x1 = get(0), y1 = get(1), x2 = get(2), y2 = get(3)
const score = get(4), cls = get(5)
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
if (score < 0 || score > 1) return null
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
},
// [cls,score,x1,y1,x2,y2]
(get) => {
const cls = get(0), score = get(1), x1 = get(2), y1 = get(3), x2 = get(4), y2 = get(5)
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
if (score < 0 || score > 1) return null
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
},
// [x,y,w,h,score,cls]xywh
(get) => {
const x = get(0), y = get(1), w = get(2), h = get(3), score = get(4), cls = get(5)
if (!isFinite(x+y+w+h+score+cls)) return null
if (score < 0 || score > 1) return null
return { x: x - w/2, y: y - h/2, w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
}
]
const out: typeof detections = []
for (let i = 0; i < numBoxes; i++) {
const getter = (j:number) => getVal(i, j)
let picked = null
for (const decode of candidates) {
picked = decode(getter)
if (picked && picked.conf >= confThreshold) break
}
if (!picked || picked.conf < confThreshold) continue
// 还原坐标
let { x, y, w, h, conf, cls } = picked
x = (x - padX) / ratio
y = (y - padY) / ratio
w = w / ratio
h = h / ratio
const area = Math.max(0, w) * Math.max(0, h)
const minArea = opts?.minBoxArea ?? (meta.srcW * meta.srcH * 0.0001)
if (area <= 0 || area < minArea) continue
out.push({ x, y, w, h, conf, class: cls })
}
return out
}
// 情况二:原始预测 [*, *, 5+num_classes],需要 obj × class 计算。
// 支持两种坐标格式xywh(中心点) 与 xyxy(左上/右下)。优先取能得到更多有效框的解码。
const decode = (mode: 'xywh' | 'xyxy') => {
const out: typeof detections = []
for (let i = 0; i < numBoxes; i++) {
const v0 = getVal(i, 0)
const v1 = getVal(i, 1)
const v2 = getVal(i, 2)
const v3 = getVal(i, 3)
const objConf = sigmoid(getVal(i, 4))
// 最大类别
let maxClassConf = 0
let maxClassIdx = 0
for (let j = 0; j < numClasses; j++) {
const classConf = sigmoid(getVal(i, 5 + j))
if (classConf > maxClassConf) {
maxClassConf = classConf
maxClassIdx = j
}
}
const confidence = objConf * maxClassConf
if (confidence < confThreshold) continue
let x = 0, y = 0, w = 0, h = 0
if (mode === 'xywh') {
const xc = (v0 - padX) / ratio
const yc = (v1 - padY) / ratio
const wv = v2 / ratio
const hv = v3 / ratio
x = xc - wv / 2
y = yc - hv / 2
w = wv
h = hv
} else {
// xyxy
const x1 = (v0 - padX) / ratio
const y1 = (v1 - padY) / ratio
const x2 = (v2 - padX) / ratio
const y2 = (v3 - padY) / ratio
x = Math.min(x1, x2)
y = Math.min(y1, y2)
w = Math.abs(x2 - x1)
h = Math.abs(y2 - y1)
}
const area = Math.max(0, w) * Math.max(0, h)
const minArea = opts?.minBoxArea ?? (originalWidth * originalHeight * 0.00005) // 放宽0.005%
if (area <= 0 || area < minArea) continue
if (w > 4 * originalWidth || h > 4 * originalHeight) continue // 明显异常
out.push({ x, y, w, h, conf: confidence, class: maxClassIdx })
}
return out
}
let pick: typeof detections = []
// 若特征维很小(<=6优先按“已NMS格式”解析
if (numFeatures <= 6) {
pick = tryPostNmsLayouts()
}
// 否则按原始格式解码
if (pick.length === 0) {
const d1 = decode('xywh')
const d2 = decode('xyxy')
pick = d2.length > d1.length ? d2 : d1
}
detections.push(...pick)
// 执行NMS支持按类别
const classWise = opts?.classWise ?? true
let kept: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
if (classWise) {
const byClass: Record<number, typeof detections> = {}
for (const d of detections) {
(byClass[d.class] ||= []).push(d)
}
for (const k in byClass) {
const group = byClass[k]
const idxs = this.nms(group, nmsThreshold)
kept.push(...idxs.map(i => group[i]))
}
} else {
const idxs = this.nms(detections, nmsThreshold)
kept = idxs.map(i => detections[i])
}
// 置信度排序并限制最大数量
kept.sort((a, b) => b.conf - a.conf)
const limited = kept.slice(0, opts?.maxDetections ?? 100)
// 构建最终结果
return limited.map(det => {
const className = this.classNames[det.class] || `class_${det.class}`
return {
class_name: className,
confidence: det.conf,
bbox: {
x: Math.max(0, det.x),
y: Math.max(0, det.y),
width: Math.min(det.w, originalWidth - det.x),
height: Math.min(det.h, originalHeight - det.y)
}
}
})
}
/**
* 在图像上绘制检测框
*/
private drawDetections(canvas: HTMLCanvasElement, detections: Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}>): void {
const ctx = canvas.getContext('2d')
if (!ctx) return
// 为每个类别分配颜色
const colors: {[key: string]: string} = {}
const colorPalette = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
detections.forEach((det, idx) => {
if (!colors[det.class_name]) {
colors[det.class_name] = colorPalette[idx % colorPalette.length]
}
})
detections.forEach(det => {
const { x, y, width, height } = det.bbox
const color = colors[det.class_name]
// 绘制边界框
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.strokeRect(x, y, width, height)
// 绘制标签背景
const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`
ctx.font = '14px Arial'
const textMetrics = ctx.measureText(label)
const textWidth = textMetrics.width
const textHeight = 20
ctx.fillStyle = color
ctx.fillRect(x, y - textHeight, textWidth + 10, textHeight)
// 绘制标签文字
ctx.fillStyle = '#FFFFFF'
ctx.fillText(label, x + 5, y - 5)
})
}
/**
* 执行检测
* @param image 图像元素Image, Video, 或 Canvas
* @param confidenceThreshold 置信度阈值
* @param nmsThreshold NMS阈值
*/
async detect(
image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement,
confidenceThreshold: number = 0.25,
nmsThreshold: number = 0.7
): Promise<YOLODetectionResult> {
if (!this.session) {
throw new Error('模型未加载,请先调用 loadModel()')
}
const startTime = performance.now()
try {
// 获取原始图像尺寸
const originalWidth = image instanceof HTMLVideoElement ? image.videoWidth : image.width
const originalHeight = image instanceof HTMLVideoElement ? image.videoHeight : image.height
// 预处理图像letterbox
const prep = this.preprocessImage(image)
// 创建输入tensor [1, 3, H, W]
const inputTensor = new ort.Tensor('float32', prep.input, [1, 3, this.inputShape[1], this.inputShape[0]])
// 执行推理
const inputName = this.session.inputNames[0]
const feeds = { [inputName]: inputTensor }
const results = await this.session.run(feeds)
// 获取输出
const outputName = this.session.outputNames[0]
const output = results[outputName]
// 后处理
const detections = this.postprocess(
output,
{ ratio: prep.ratio, padX: prep.padX, padY: prep.padY, srcW: originalWidth, srcH: originalHeight },
confidenceThreshold,
nmsThreshold,
{ maxDetections: 100, minBoxArea: originalWidth * originalHeight * 0.0001, classWise: true }
)
// 计算统计信息
const objectCount = detections.length
const detectedCategories = [...new Set(detections.map(d => d.class_name))]
const confidenceScores = detections.map(d => d.confidence)
const avgConfidence = confidenceScores.length > 0
? confidenceScores.reduce((a, b) => a + b, 0) / confidenceScores.length
: 0
// 绘制检测结果
const canvas = document.createElement('canvas')
canvas.width = originalWidth
canvas.height = originalHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(image, 0, 0, originalWidth, originalHeight)
this.drawDetections(canvas, detections)
}
// 转换为base64降低质量减少内存与传输开销
const annotatedImage = canvas.toDataURL('image/jpeg', 0.4)
const processingTime = (performance.now() - startTime) / 1000
return {
detections: detections.map(d => ({
class_name: d.class_name,
confidence: d.confidence,
bbox: d.bbox
})),
object_count: objectCount,
detected_categories: detectedCategories,
confidence_scores: confidenceScores,
avg_confidence: avgConfidence,
annotated_image: annotatedImage,
processing_time: processingTime
}
} catch (error) {
console.error('❌ 检测失败:', error)
// 若 GPU 后端不支持某些算子,自动回退到 WASM 并重试一次
const msg = String((error as any)?.message || error)
const needFallback = /GatherND|Unsupported data type|JSF Kernel|ExecuteKernel|WebGPU|WebGL|worker not ready/i.test(msg)
if (needFallback && this.currentEP !== 'wasm') {
try {
console.warn('⚠️ 检测算子不被 GPU 支持,自动回退到 WASM 并重试一次。')
// 强制全局与本次调用走 WASM
localStorage.setItem('ort_force_wasm','1')
await this.loadModel(this.modelPath, this.classNames, 'wasm')
// 强制使用 wasm
// @ts-ignore
ort.env.wasm.proxy = true
this.currentEP = 'wasm'
return await this.detect(image, confidenceThreshold, nmsThreshold)
} catch (e2) {
console.error('❌ 回退到 WASM 后仍失败:', e2)
}
}
// 如果已是 wasm但报 worker not ready再降级为单线程重建 session
if (/worker not ready/i.test(msg) && this.currentEP === 'wasm') {
try {
// @ts-ignore
ort.env.wasm.numThreads = 1
await this.loadModel(this.modelPath, this.classNames)
return await this.detect(image, confidenceThreshold, nmsThreshold)
} catch (e3) {
console.error('❌ 降级单线程后仍失败:', e3)
}
}
throw new Error(`检测失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 释放模型资源
*/
dispose(): void {
if (this.session) {
// ONNX Runtime会自动管理资源但我们可以清理引用
this.session = null
this.modelPath = ''
console.log('🗑️ 模型资源已释放')
}
}
}
// 导出单例
export const yoloDetector = new YOLODetector()

View File

@@ -1,505 +0,0 @@
<template>
<div class="home-container">
<!-- 简约导航栏 -->
<header class="header">
<div class="header-content">
<div class="logo-section">
<div class="logo-text">
<span class="brand-name">管理系统</span>
</div>
</div>
<nav class="navigation">
<a href="#home" class="nav-link">首页</a>
<a href="#about" class="nav-link">关于</a>
<a href="#service" class="nav-link">服务</a>
<a href="#contact" class="nav-link">联系</a>
</nav>
<div class="header-actions">
<button class="btn-login" @click="goToLogin">
登录
</button>
</div>
</div>
</header>
<!-- 英雄区域 -->
<section class="hero-section" id="home">
<div class="hero-content">
<h1 class="hero-title">现代化管理系统模板</h1>
<p class="hero-description">
简洁高效易用的后台管理系统解决方案
</p>
<div class="hero-actions">
<button class="btn-primary" @click="goToLogin">
开始使用
</button>
<button class="btn-secondary">
了解更多
</button>
</div>
</div>
</section>
<!-- 特性区域 -->
<section class="features-section" id="service">
<div class="container">
<div class="section-header">
<h2 class="section-title">核心特性</h2>
<p class="section-subtitle">提供全方位的功能支持</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3 class="feature-title">数据可视化</h3>
<p class="feature-description">
直观的图表展示让数据一目了然
</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔐</div>
<h3 class="feature-title">权限管理</h3>
<p class="feature-description">
完善的权限控制体系保障系统安全
</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3 class="feature-title">高性能</h3>
<p class="feature-description">
优化的架构设计提供流畅的使用体验
</p>
</div>
<div class="feature-card">
<div class="feature-icon">📱</div>
<h3 class="feature-title">响应式设计</h3>
<p class="feature-description">
完美适配各种设备随时随地访问
</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3 class="feature-title">界面美观</h3>
<p class="feature-description">
现代化的UI设计提升用户体验
</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔧</div>
<h3 class="feature-title">易于扩展</h3>
<p class="feature-description">
模块化设计轻松添加新功能
</p>
</div>
</div>
</div>
</section>
<!-- 关于区域 -->
<section class="about-section" id="about">
<div class="container">
<div class="about-content">
<div class="about-text">
<h2 class="about-title">关于系统</h2>
<p class="about-description">
这是一个现代化的后台管理系统模板采用最新的技术栈构建
提供完善的功能模块和优雅的用户界面帮助您快速搭建企业级应用
</p>
<ul class="about-features">
<li>基于 Vue 3 + TypeScript</li>
<li>Ant Design Vue 组件库</li>
<li>响应式布局设计</li>
<li>完整的权限管理</li>
<li>丰富的功能模块</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 页脚 -->
<footer class="footer" id="contact">
<div class="container">
<div class="footer-content">
<div class="footer-info">
<h3 class="footer-title">管理系统模板</h3>
<p class="footer-description">
现代化的后台管理系统解决方案
</p>
</div>
<div class="footer-links">
<div class="link-group">
<h4>快速链接</h4>
<ul>
<li><a href="#" @click="goToLogin">登录系统</a></li>
<li><a href="#about">关于我们</a></li>
<li><a href="#service">功能介绍</a></li>
</ul>
</div>
<div class="link-group">
<h4>联系方式</h4>
<ul>
<li>邮箱contact@example.com</li>
<li>电话+86 123-4567-8900</li>
</ul>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2024 管理系统模板. All rights reserved.</p>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goToLogin = () => {
router.push('/login')
}
</script>
<style scoped lang="scss">
.home-container {
min-height: 100vh;
background: #ffffff;
}
// 导航栏
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-content {
max-width: 1280px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 64px;
}
.logo-section {
.logo-text {
.brand-name {
font-size: 20px;
font-weight: 600;
color: #111827;
}
}
}
.navigation {
display: flex;
gap: 32px;
.nav-link {
color: #6b7280;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
&:hover {
color: #2563eb;
}
}
}
.header-actions {
.btn-login {
padding: 8px 20px;
background: #2563eb;
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #1d4ed8;
}
}
}
// 英雄区域
.hero-section {
padding: 160px 24px 120px;
text-align: center;
background: #ffffff;
}
.hero-content {
max-width: 800px;
margin: 0 auto;
}
.hero-title {
font-size: 48px;
font-weight: 700;
color: #111827;
margin-bottom: 24px;
line-height: 1.2;
}
.hero-description {
font-size: 20px;
color: #6b7280;
margin-bottom: 48px;
line-height: 1.6;
}
.hero-actions {
display: flex;
justify-content: center;
gap: 16px;
.btn-primary {
padding: 14px 32px;
background: #2563eb;
border: none;
border-radius: 8px;
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #1d4ed8;
}
}
.btn-secondary {
padding: 14px 32px;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #2563eb;
color: #2563eb;
}
}
}
// 特性区域
.features-section {
padding: 80px 24px;
background: #f9fafb;
}
.container {
max-width: 1280px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 64px;
}
.section-title {
font-size: 36px;
font-weight: 700;
color: #111827;
margin-bottom: 16px;
}
.section-subtitle {
font-size: 18px;
color: #6b7280;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 32px;
}
.feature-card {
background: #ffffff;
padding: 32px;
border-radius: 12px;
border: 1px solid #e5e7eb;
text-align: center;
transition: all 0.2s;
&:hover {
border-color: #2563eb;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
}
}
.feature-icon {
font-size: 48px;
margin-bottom: 20px;
}
.feature-title {
font-size: 20px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
}
.feature-description {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
}
// 关于区域
.about-section {
padding: 80px 24px;
background: #ffffff;
}
.about-content {
max-width: 800px;
margin: 0 auto;
}
.about-text {
text-align: center;
}
.about-title {
font-size: 36px;
font-weight: 700;
color: #111827;
margin-bottom: 24px;
}
.about-description {
font-size: 16px;
color: #6b7280;
line-height: 1.8;
margin-bottom: 32px;
}
.about-features {
list-style: none;
padding: 0;
display: inline-block;
text-align: left;
li {
padding: 8px 0;
color: #374151;
font-size: 15px;
&::before {
content: '✓';
color: #10b981;
font-weight: bold;
margin-right: 12px;
}
}
}
// 页脚
.footer {
background: #f9fafb;
border-top: 1px solid #e5e7eb;
padding: 64px 24px 32px;
}
.footer-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
margin-bottom: 48px;
}
.footer-info {
.footer-title {
font-size: 20px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
}
.footer-description {
color: #6b7280;
font-size: 14px;
line-height: 1.6;
}
}
.footer-links {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
.link-group {
h4 {
color: #111827;
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
margin-bottom: 8px;
font-size: 14px;
color: #6b7280;
a {
color: #6b7280;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #2563eb;
}
}
}
}
}
}
.footer-bottom {
text-align: center;
padding-top: 32px;
border-top: 1px solid #e5e7eb;
p {
color: #9ca3af;
font-size: 14px;
margin: 0;
}
}
</style>

View File

@@ -1,464 +0,0 @@
<template>
<div class="login-container">
<!-- 左侧区域 -->
<div class="left-section">
<div class="welcome-content">
<h1 class="welcome-title">欢迎使用</h1>
<h2 class="system-name">管理系统模板</h2>
<p class="welcome-description">
现代化的后台管理系统解决方案
</p>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="right-section">
<div class="login-card">
<div class="login-header">
<h1 class="login-title">{{ $t('login.title') }}</h1>
<p class="login-subtitle">请输入您的登录信息</p>
</div>
<a-form
:model="form"
:rules="rules"
@finish="handleLogin"
layout="vertical"
class="login-form"
>
<a-form-item
:label="$t('login.username')"
name="username"
>
<a-input
v-model:value="form.username"
:placeholder="$t('login.username')"
size="large"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
:label="$t('login.password')"
name="password"
>
<a-input-password
v-model:value="form.password"
:placeholder="$t('login.password')"
size="large"
>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item
label="验证码"
name="captcha"
>
<a-row :gutter="8">
<a-col :span="14">
<a-input
v-model:value="form.captcha"
placeholder="请输入验证码"
size="large"
>
<template #prefix>
<SafetyOutlined />
</template>
</a-input>
</a-col>
<a-col :span="10">
<div class="captcha-container">
<img
v-if="captchaData?.image_data"
:src="captchaData.image_data"
alt="验证码"
class="captcha-image"
@click="handleRefreshCaptcha"
/>
<a-button
v-else
size="large"
:loading="captchaLoading"
@click="handleRefreshCaptcha"
block
>
获取验证码
</a-button>
</div>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<div class="login-options">
<a-checkbox v-model:checked="form.remember">
{{ $t('login.rememberMe') }}
</a-checkbox>
<a href="#" class="forgot-password">{{ $t('login.forgotPassword') }}</a>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="loading"
block
class="login-button"
>
{{ $t('login.login') }}
</a-button>
</a-form-item>
<div class="register-link">
还没有账户
<a @click="goToRegister">立即注册</a>
</div>
</a-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/hertz_user'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
UserOutlined,
LockOutlined,
SafetyOutlined
} from '@ant-design/icons-vue'
import { useCaptcha } from '@/utils/hertz_captcha'
import { loginUser } from '@/api'
import { errorHandler, handleSuccess } from '@/utils/hertz_error_handler'
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n()
// 初始化错误处理器的i18n实例
errorHandler.setI18n({ t })
const loading = ref(false)
// 验证码相关
const { captchaData, loading: captchaLoading, generateCaptcha, refreshCaptcha } = useCaptcha()
const form = reactive({
username: '',
password: '',
captcha: '',
remember: false,
})
const rules = {
username: [
{ required: true, message: t('error.usernameRequired'), trigger: 'blur' },
],
password: [
{ required: true, message: t('error.passwordRequired'), trigger: 'blur' },
],
captcha: [
{ required: true, message: t('error.captchaRequired'), trigger: 'blur' },
],
}
const handleLogin = async () => {
if (loading.value) return
// 验证表单
if (!form.username || !form.password || !form.captcha) {
message.error(t('error.requiredFieldMissing'))
return
}
// 检查验证码数据是否存在
if (!captchaData.value?.captcha_id) {
message.error(t('error.captchaExpired'))
await handleRefreshCaptcha()
return
}
loading.value = true
try {
// 构建登录数据 - 严格按照API接口定义
const loginData = {
username: form.username,
password: form.password,
captcha_code: form.captcha.trim(),
captcha_key: captchaData.value.captcha_id
}
const response = await loginUser(loginData)
// 设置用户状态到store
if (response.data) {
// 设置token - 使用后端返回的access_token
if (response.data.access_token) {
userStore.token = response.data.access_token
localStorage.setItem('token', response.data.access_token)
}
// 设置用户信息
if (response.data.user_info) {
userStore.userInfo = response.data.user_info
userStore.isLoggedIn = true
localStorage.setItem('userInfo', JSON.stringify(response.data.user_info))
}
}
handleSuccess('login')
// 根据用户角色跳转到对应首页
const userRole = response.data?.user_info?.roles?.[0]?.role_code
// 仅管理员角色进入管理端,其余(含未定义)进入用户端
const adminRoles = ['admin', 'system_admin', 'super_admin']
const isAdmin = adminRoles.includes(userRole as any)
if (isAdmin) {
router.push('/admin')
} else {
router.push('/dashboard')
}
} catch (error: any) {
console.error('登录失败:', error)
// 清除敏感字段
form.password = ''
form.captcha = ''
// 刷新验证码
await handleRefreshCaptcha()
} finally {
loading.value = false
}
}
const handleRefreshCaptcha = async () => {
try {
await refreshCaptcha()
// 清空验证码输入
form.captcha = ''
} catch (error) {
message.error('刷新验证码失败')
}
}
const goToRegister = () => {
router.push('/register')
}
// 页面加载时生成验证码
onMounted(() => {
generateCaptcha()
})
</script>
<style scoped>
.login-container {
display: flex;
min-height: 100vh;
background: #ffffff;
}
/* 左侧区域 */
.left-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
background: #f9fafb;
}
.welcome-content {
max-width: 500px;
}
.welcome-title {
font-size: 24px;
font-weight: 400;
color: #6b7280;
margin-bottom: 16px;
}
.system-name {
font-size: 48px;
font-weight: 700;
color: #111827;
margin-bottom: 24px;
line-height: 1.2;
}
.welcome-description {
font-size: 18px;
color: #6b7280;
line-height: 1.6;
}
/* 右侧登录表单 */
.right-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
background: #ffffff;
}
.login-card {
background: #ffffff;
padding: 48px;
border-radius: 12px;
width: 100%;
max-width: 420px;
border: 1px solid #e5e7eb;
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-title {
font-size: 28px;
font-weight: 700;
color: #111827;
margin-bottom: 8px;
}
.login-subtitle {
color: #6b7280;
font-size: 14px;
margin: 0;
}
.login-form {
width: 100%;
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgot-password {
color: #2563eb;
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.forgot-password:hover {
color: #1d4ed8;
}
.register-link {
text-align: center;
margin-top: 24px;
color: #6b7280;
font-size: 14px;
}
.register-link a {
color: #2563eb;
text-decoration: none;
margin-left: 4px;
cursor: pointer;
transition: color 0.2s;
}
.register-link a:hover {
color: #1d4ed8;
}
:deep(.ant-form-item-label > label) {
font-weight: 600;
color: #111827;
font-size: 14px;
}
:deep(.ant-input-affix-wrapper) {
border-radius: 8px;
border: 1px solid #d1d5db;
transition: all 0.2s;
}
:deep(.ant-input-affix-wrapper:focus),
:deep(.ant-input-affix-wrapper-focused) {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
:deep(.ant-input) {
border: none;
font-size: 14px;
}
:deep(.ant-input-prefix) {
color: #9ca3af;
margin-right: 8px;
}
:deep(.ant-btn-primary) {
background: #2563eb;
border-color: #2563eb;
border-radius: 8px;
height: 44px;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
}
:deep(.ant-btn-primary:hover) {
background: #1d4ed8;
border-color: #1d4ed8;
}
:deep(.ant-checkbox-wrapper) {
font-size: 14px;
color: #6b7280;
}
:deep(.ant-checkbox-checked .ant-checkbox-inner) {
background-color: #2563eb;
border-color: #2563eb;
}
:deep(.ant-form-item) {
margin-bottom: 20px;
}
.captcha-container {
height: 40px;
display: flex;
align-items: center;
}
.captcha-image {
width: 100%;
height: 40px;
border-radius: 8px;
border: 1px solid #d1d5db;
cursor: pointer;
transition: border-color 0.2s;
object-fit: cover;
}
.captcha-image:hover {
border-color: #2563eb;
}
</style>

View File

@@ -1,65 +0,0 @@
<template>
<div class="not-found-container">
<div class="not-found-content">
<h1>404</h1>
<h2>{{ $t('error.404') }}</h2>
<p>抱歉您访问的页面不存在</p>
<router-link to="/" class="back-home-btn">
返回首页
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
// 404页面不需要额外的逻辑
</script>
<style scoped>
.not-found-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.not-found-content {
text-align: center;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.not-found-content h1 {
font-size: 72px;
margin: 0;
color: #1890ff;
}
.not-found-content h2 {
font-size: 24px;
margin: 16px 0;
color: #333;
}
.not-found-content p {
color: #666;
margin-bottom: 24px;
}
.back-home-btn {
display: inline-block;
padding: 12px 24px;
background: #1890ff;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
.back-home-btn:hover {
background: #40a9ff;
}
</style>

View File

@@ -1,884 +0,0 @@
<template>
<div class="alert-level-management">
<div class="page-header">
<h2 class="page-title">
<WarningOutlined class="title-icon" />
模型类别管理
</h2>
<p class="page-description">管理YOLO检测的警告等级配置</p>
</div>
<!-- 操作栏 -->
<div class="action-bar">
<div class="action-left">
<a-button @click="refreshLevels">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</div>
<div class="action-right">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索警告等级名称"
style="width: 250px"
@search="handleSearch"
/>
</div>
</div>
<!-- 警告等级表格 -->
<a-table
:columns="columns"
:data-source="filteredLevels"
:loading="loading"
:pagination="pagination"
row-key="id"
class="levels-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
{{ record.name }}
</template>
<template v-if="column.key === 'display_name'">
{{ record.display_name }}
</template>
<template v-if="column.key === 'alias'">
<div class="alias-cell">
<div v-if="!record.editingAlias" class="alias-display" @click="startEditAlias(record)">
<span class="alias-text">{{ record.alias || '点击编辑' }}</span>
<EditOutlined class="edit-icon" />
</div>
<a-input
v-else
v-model:value="record.tempAlias"
size="small"
placeholder="请输入别名"
@blur="saveAlias(record)"
@pressEnter="saveAlias(record)"
@keyup.esc="cancelEditAlias(record)"
ref="aliasInput"
class="alias-input"
/>
</div>
</template>
<template v-if="column.key === 'alert_level'">
<a-tag :color="getAlertLevelColor(record.alert_level)">
{{ record.alert_level_display }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'actions'">
<div class="action-buttons">
<a-button size="small" @click="editAlertLevel(record)">
<template #icon>
<EditOutlined />
</template>
切换等级
</a-button>
<a-button
size="small"
:type="record.is_active ? 'default' : 'primary'"
@click="toggleStatus(record)"
>
<template #icon>
<PoweroffOutlined />
</template>
{{ record.is_active ? '禁用' : '启用' }}
</a-button>
</div>
</template>
</template>
</a-table>
<!-- 详情弹窗 -->
<a-modal
v-model:visible="detailModalVisible"
title="警告等级详情"
:footer="null"
width="600px"
>
<div v-if="currentLevel" class="level-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="名称">
{{ currentLevel.name }}
</a-descriptions-item>
<a-descriptions-item label="显示名称">
{{ currentLevel.display_name }}
</a-descriptions-item>
<a-descriptions-item label="警告等级">
<a-tag :color="getAlertLevelColor(currentLevel.alert_level)">
{{ currentLevel.alert_level_display }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentLevel.is_active ? 'green' : 'red'">
{{ currentLevel.is_active ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="模型名称">
{{ currentLevel.model_name }}
</a-descriptions-item>
<a-descriptions-item label="类别ID">
{{ currentLevel.category_id }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
<!-- 切换警告等级弹窗 -->
<a-modal
v-model:visible="editModalVisible"
title="切换警告等级"
@ok="handleEdit"
@cancel="handleEditCancel"
width="500px"
>
<div v-if="currentLevel" class="alert-level-edit">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="名称">
{{ currentLevel.name }}
</a-descriptions-item>
<a-descriptions-item label="显示名称">
{{ currentLevel.display_name }}
</a-descriptions-item>
<a-descriptions-item label="当前警告等级">
<a-tag :color="getAlertLevelColor(currentLevel.alert_level)">
{{ currentLevel.alert_level_display }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
layout="vertical"
style="margin-top: 20px"
>
<a-form-item label="新的警告等级" name="alert_level">
<a-select
v-model:value="editForm.alert_level"
placeholder="请选择新的警告等级"
style="width: 100%"
>
<a-select-option value="low"></a-select-option>
<a-select-option value="medium"></a-select-option>
<a-select-option value="high"></a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import {
WarningOutlined,
ReloadOutlined,
EyeOutlined,
EditOutlined,
PoweroffOutlined
} from '@ant-design/icons-vue'
import { yoloApi, type AlertLevel } from '@/api/yolo'
import dayjs from 'dayjs'
// 响应式数据
const loading = ref(false)
const editing = ref(false)
const levels = ref<AlertLevel[]>([])
const searchKeyword = ref('')
// 弹窗状态
const detailModalVisible = ref(false)
const editModalVisible = ref(false)
const currentLevel = ref<AlertLevel | null>(null)
// 编辑表单
const editForm = reactive({
alert_level: 'low' as 'low' | 'medium' | 'high'
})
// 表单引用
const editFormRef = ref()
// 表单验证规则
const editRules = {
alert_level: [
{ required: true, message: '请选择警告等级', trigger: 'change' }
]
}
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total} 条记录`
})
// 表格列配置
const columns = [
{
title: '名称',
key: 'name',
width: 120
},
{
title: '显示名称',
key: 'display_name',
width: 150
},
{
title: '别名',
key: 'alias',
width: 120
},
{
title: '警告等级',
key: 'alert_level',
width: 120
},
{
title: '状态',
key: 'status',
width: 100
},
{
title: '操作',
key: 'actions',
width: 200
}
]
// 过滤后的等级列表
const filteredLevels = computed(() => {
if (!searchKeyword.value) {
return levels.value
}
return levels.value.filter(level =>
level.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
level.display_name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
level.alert_level_display.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
// 格式化日期
const formatDate = (dateString: string) => {
return dayjs(dateString).format('YYYY-MM-DD HH:mm:ss')
}
// 获取警告等级颜色
const getAlertLevelColor = (level: string) => {
const colorMap = {
low: 'green',
medium: 'orange',
high: 'red'
}
return colorMap[level] || 'default'
}
// 获取警告等级列表
const fetchLevels = async () => {
try {
loading.value = true
console.log('🔍 开始获取警告等级列表...')
const response = await yoloApi.getAlertLevels()
console.log('📋 警告等级API响应:', response)
if (response.success && response.data) {
levels.value = response.data
pagination.total = response.data.length
console.log('✅ 警告等级获取成功:', levels.value)
} else {
console.error('❌ 获取警告等级失败:', response.message)
message.error(response.message || '获取警告等级失败')
}
} catch (error) {
console.error('❌ 获取警告等级异常:', error)
message.error('获取警告等级失败,请检查网络连接')
} finally {
loading.value = false
}
}
// 刷新等级列表
const refreshLevels = () => {
fetchLevels()
}
// 查看详情
const viewDetails = (level: AlertLevel) => {
currentLevel.value = level
detailModalVisible.value = true
}
// 编辑警告等级
const editAlertLevel = (level: AlertLevel) => {
currentLevel.value = level
editForm.alert_level = level.alert_level
editModalVisible.value = true
}
// 开始编辑alias
const startEditAlias = (record: AlertLevel) => {
record.editingAlias = true
record.tempAlias = record.alias
// 使用nextTick确保DOM更新后再聚焦
nextTick(() => {
const input = document.querySelector('.alias-cell input') as HTMLInputElement
if (input) {
input.focus()
input.select()
}
})
}
// 保存alias
const saveAlias = async (record: AlertLevel) => {
const newAlias = record.tempAlias?.trim() || ''
if (newAlias === record.alias) {
// 没有变化,直接取消编辑
cancelEditAlias(record)
return
}
try {
console.log('🔍 开始更新alias...', record.id, newAlias)
// 调用API更新alias
const response = await yoloApi.updateAlertLevel(record.id.toString(), {
alias: newAlias
})
console.log('📋 更新alias API响应:', response)
if (response.success) {
record.alias = newAlias
message.success('别名更新成功')
} else {
console.error('❌ 更新alias失败:', response.message)
message.error(response.message || '更新别名失败')
}
} catch (error) {
console.error('❌ 更新alias异常:', error)
message.error('更新别名失败,请检查网络连接')
} finally {
record.editingAlias = false
// 不要清空tempAlias保持数据一致性
}
}
// 取消编辑alias
const cancelEditAlias = (record: AlertLevel) => {
record.editingAlias = false
record.tempAlias = ''
}
// 处理编辑
const handleEdit = async () => {
try {
await editFormRef.value.validate()
if (!currentLevel.value) return
editing.value = true
console.log('🔍 开始切换警告等级...', currentLevel.value.id, editForm)
// 构造更新数据 - 只传递 alert_level
const updateData = {
alert_level: editForm.alert_level
}
console.log('📤 发送更新数据:', updateData)
const response = await yoloApi.updateAlertLevel(currentLevel.value.id.toString(), updateData)
console.log('📋 更新警告等级API响应:', response)
if (response.success) {
message.success(`警告等级已切换为: ${editForm.alert_level}`)
editModalVisible.value = false
fetchLevels()
} else {
console.error('❌ 切换警告等级失败:', response.message)
message.error(response.message || '切换失败')
}
} catch (error) {
console.error('❌ 切换警告等级异常:', error)
message.error('切换失败')
} finally {
editing.value = false
}
}
// 取消编辑
const handleEditCancel = () => {
editModalVisible.value = false
currentLevel.value = null
}
// 切换状态
const toggleStatus = async (level: AlertLevel) => {
try {
console.log('🔍 开始切换警告等级状态...', level.id)
const response = await yoloApi.toggleAlertLevelStatus(level.id.toString())
console.log('📋 切换状态API响应:', response)
if (response.success) {
message.success(level.is_active ? '警告等级已禁用' : '警告等级已启用')
fetchLevels()
} else {
console.error('❌ 切换状态失败:', response.message)
message.error(response.message || '状态切换失败')
}
} catch (error) {
console.error('❌ 切换状态异常:', error)
message.error('状态切换失败')
}
}
// 搜索处理
const handleSearch = () => {
// 搜索逻辑已在computed中处理
}
// 组件挂载时获取数据
onMounted(() => {
fetchLevels()
})
</script>
<style scoped lang="scss">
.alert-level-management {
padding: 0;
background: #f5f5f7;
min-height: 100vh;
// 页面头部 - 苹果风格
.page-header {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
padding: 32px 28px;
margin-bottom: 24px;
border-radius: 0;
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.08);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
.page-title {
display: flex;
align-items: center;
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1d1d1f;
letter-spacing: -0.3px;
line-height: 1.2;
.title-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(245, 158, 11, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
color: #f59e0b;
font-size: 24px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
transform: scale(1.05);
background: rgba(245, 158, 11, 0.15);
}
}
}
.page-description {
margin: 0;
color: #86868b;
font-size: 14px;
font-weight: 400;
padding-left: 64px;
}
}
// 操作栏 - 苹果风格
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 28px 24px 28px;
padding: 20px 24px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 0.5px solid rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
flex-wrap: wrap;
gap: 16px;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: rgba(0, 0, 0, 0.12);
}
.action-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
.ant-btn {
border-radius: 12px;
height: 40px;
padding: 0 20px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
border-color: rgba(0, 0, 0, 0.12);
color: #1d1d1f;
background: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.16);
transform: translateY(-1px);
}
}
}
.action-right {
display: flex;
align-items: center;
:deep(.ant-input-search) {
.ant-input {
border-radius: 10px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
height: 40px;
&:hover {
border-color: #3b82f6;
}
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.ant-btn {
border-radius: 0 10px 10px 0;
height: 40px;
}
}
}
}
// 表格容器 - 苹果风格
.levels-table {
margin: 0 28px 24px 28px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 0.5px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: rgba(0, 0, 0, 0.12);
}
:deep(.ant-table) {
background: transparent;
.ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
font-size: 13px;
letter-spacing: -0.1px;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba(0, 0, 0, 0.02);
}
> td {
padding: 16px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}
}
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
.ant-btn {
border-radius: 8px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
transform: translateY(-1px);
}
}
}
.alias-cell {
.alias-display {
cursor: pointer;
padding: 8px 12px;
border-radius: 10px;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
display: flex;
align-items: center;
justify-content: space-between;
min-width: 100px;
border: 0.5px dashed rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
position: relative;
&:hover {
border-color: #3b82f6;
background: rgba(255, 255, 255, 1);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
.alias-text {
color: #3b82f6;
font-weight: 500;
}
.edit-icon {
color: #3b82f6;
transform: scale(1.1);
}
}
.alias-text {
color: #1d1d1f;
font-size: 13px;
transition: all 0.2s ease;
flex: 1;
&:empty::before {
content: '点击编辑';
color: #86868b;
font-style: italic;
}
}
.edit-icon {
color: #86868b;
font-size: 12px;
margin-left: 6px;
transition: all 0.2s ease;
}
}
.alias-input {
border: 0.5px solid #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
border-radius: 10px !important;
background: rgba(255, 255, 255, 1) !important;
&:focus {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15) !important;
}
}
}
// 标签样式优化
:deep(.ant-tag) {
border-radius: 6px;
font-weight: 500;
padding: 2px 10px;
border: 0.5px solid currentColor;
opacity: 0.8;
}
}
.level-detail {
:deep(.ant-descriptions-item-label) {
font-weight: 500;
color: #1d1d1f;
background: rgba(0, 0, 0, 0.02);
}
:deep(.ant-descriptions-item-content) {
color: #86868b;
}
}
// 分页器样式优化 - 苹果风格
:deep(.ant-pagination) {
margin: 20px 0;
padding: 16px 0;
text-align: center;
background: rgba(0, 0, 0, 0.02);
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
.ant-pagination-total-text {
color: #86868b;
font-size: 13px;
font-weight: 400;
}
.ant-pagination-item {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
margin: 0 4px;
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
}
&.ant-pagination-item-active {
background: #3b82f6;
border-color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
a {
color: white;
font-weight: 600;
}
}
}
.ant-pagination-prev,
.ant-pagination-next {
border-radius: 8px;
margin: 0 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
}
}
.ant-select {
margin: 0 8px;
.ant-select-selector {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
}
}
}
}
}
// 响应式设计
@media (max-width: 1200px) {
.alert-level-management {
.page-header,
.action-bar,
.levels-table {
margin-left: 20px;
margin-right: 20px;
}
}
}
@media (max-width: 768px) {
.alert-level-management {
.page-header {
padding: 24px 16px;
.page-title {
font-size: 20px;
.title-icon {
width: 40px;
height: 40px;
font-size: 20px;
margin-right: 12px;
}
}
.page-description {
font-size: 13px;
padding-left: 52px;
}
}
.action-bar {
margin: 0 16px 20px 16px;
padding: 16px;
flex-direction: column;
align-items: stretch;
.action-left {
width: 100%;
justify-content: flex-start;
}
.action-right {
width: 100%;
:deep(.ant-input-search) {
width: 100% !important;
}
}
}
.levels-table {
margin: 0 16px 20px 16px;
border-radius: 12px;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,998 +0,0 @@
<template>
<div class="department-management">
<!-- 页面头部 - 苹果风格 -->
<div class="page-header">
<div class="header-content">
<div class="header-icon-wrapper">
<ApartmentOutlined class="header-icon" />
</div>
<div class="header-text">
<h1 class="page-title">部门管理</h1>
<p class="page-description">管理组织架构和部门信息</p>
</div>
</div>
</div>
<!-- 操作栏 - 苹果风格 -->
<div class="action-bar">
<div class="button-section">
<a-button type="primary" @click="handleAdd" class="action-btn-primary">
<template #icon><PlusOutlined /></template>
添加部门
</a-button>
<a-button @click="refreshData" class="action-btn-secondary">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="expandAll" class="action-btn-secondary">
<template #icon><ExpandAltOutlined /></template>
展开全部
</a-button>
<a-button @click="collapseAll" class="action-btn-secondary">
<template #icon><ShrinkOutlined /></template>
收起全部
</a-button>
</div>
</div>
<!-- 部门树形表格 -->
<div class="table-container">
<a-table
:columns="columns"
:data-source="paginatedData"
:loading="loading"
:pagination="pagination"
:expanded-row-keys="expandedKeys"
@expand="onExpand"
@change="handleTableChange"
row-key="dept_id"
size="middle"
:scroll="{ x: 1200 }"
>
<!-- 部门名称列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dept_name'">
<div class="dept-name-container">
<span
v-if="hasChildren(record)"
class="toggle-icon"
@click.stop="toggleChildren(record)"
>
<CaretDownOutlined v-if="isExpanded(record.dept_id)" />
<CaretRightOutlined v-else />
</span>
<span class="dept-name">{{ record.dept_name }}</span>
<span v-if="hasChildren(record)" class="children-count">({{ getChildrenCount(record) }})</span>
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record.dept_id)">
查看详情
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="link" size="small" @click="handleAddChild(record)">
添加子部门
</a-button>
<a-popconfirm
title="确定要删除这个部门吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.dept_id)"
>
<a-button type="link" size="small" danger>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 添加/编辑部门弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalMode === 'add' ? '添加部门' : '编辑部门'"
:confirm-loading="modalLoading"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="600px"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="上级部门" name="parent_id">
<a-tree-select
v-model:value="formData.parent_id"
:tree-data="departmentTreeOptions"
placeholder="请选择上级部门"
tree-default-expand-all
:field-names="{ children: 'children', label: 'dept_name', value: 'dept_id' }"
allow-clear
/>
</a-form-item>
<a-form-item label="部门名称" name="dept_name">
<a-input v-model:value="formData.dept_name" placeholder="请输入部门名称" />
</a-form-item>
<a-form-item label="部门编码" name="dept_code">
<a-input v-model:value="formData.dept_code" placeholder="请输入部门编码" />
</a-form-item>
<a-form-item label="负责人" name="leader">
<a-input v-model:value="formData.leader" placeholder="请输入负责人姓名" />
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱地址" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort_order">
<a-input-number
v-model:value="formData.sort_order"
:min="0"
:max="9999"
placeholder="请输入排序值"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 部门详情弹窗 -->
<a-modal
v-model:visible="detailVisible"
title="部门详情"
:footer="null"
width="600px"
>
<a-spin :spinning="detailLoading">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="部门ID">
{{ departmentDetail?.dept_id }}
</a-descriptions-item>
<a-descriptions-item label="部门名称">
{{ departmentDetail?.dept_name }}
</a-descriptions-item>
<a-descriptions-item label="部门编码">
{{ departmentDetail?.dept_code }}
</a-descriptions-item>
<a-descriptions-item label="上级部门ID">
{{ departmentDetail?.parent_id || '无' }}
</a-descriptions-item>
<a-descriptions-item label="负责人">
{{ departmentDetail?.leader }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ departmentDetail?.phone || '未设置' }}
</a-descriptions-item>
<a-descriptions-item label="邮箱">
{{ departmentDetail?.email || '未设置' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="departmentDetail?.status === 1 ? 'green' : 'red'">
{{ departmentDetail?.status === 1 ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="排序">
{{ departmentDetail?.sort_order }}
</a-descriptions-item>
<a-descriptions-item label="用户数量">
{{ departmentDetail?.user_count || 0 }}
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">
{{ departmentDetail?.created_at ? new Date(departmentDetail.created_at).toLocaleString() : '' }}
</a-descriptions-item>
<a-descriptions-item label="更新时间" :span="2">
{{ departmentDetail?.updated_at ? new Date(departmentDetail.updated_at).toLocaleString() : '' }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
PlusOutlined,
ReloadOutlined,
ExpandAltOutlined,
ShrinkOutlined,
CaretDownOutlined,
CaretRightOutlined,
ApartmentOutlined
} from '@ant-design/icons-vue'
import { departmentApi, type Department, type CreateDepartmentParams } from '@/api'
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const modalLoading = ref(false)
const modalMode = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const expandedKeys = ref<number[]>([])
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 5,
total: 0,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '20', '50'],
showTotal: (total: number) => `${total}`,
hideOnSinglePage: false,
})
// 判断部门是否有子部门
const hasChildren = (dept: Department): boolean => {
return dept.children && dept.children.length > 0
}
// 获取子部门数量
const getChildrenCount = (dept: Department): number => {
return dept.children ? dept.children.length : 0
}
// 判断部门是否展开
const isExpanded = (deptId: number): boolean => {
return expandedKeys.value.includes(deptId)
}
// 切换部门折叠状态
const toggleChildren = (dept: Department) => {
const index = expandedKeys.value.indexOf(dept.dept_id)
if (index > -1) {
// 如果已展开,则折叠
expandedKeys.value.splice(index, 1)
} else {
// 如果已折叠,则展开
expandedKeys.value.push(dept.dept_id)
}
}
// 分页后的数据
const paginatedData = computed(() => {
const startIndex = (pagination.current - 1) * pagination.pageSize;
const endIndex = startIndex + pagination.pageSize;
pagination.total = departmentTree.value.length;
return departmentTree.value.slice(startIndex, endIndex);
})
// 表格变化处理
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
}
// 部门详情相关
const detailVisible = ref(false)
const detailLoading = ref(false)
const departmentDetail = ref<Department | null>(null)
// 部门数据
const departmentList = ref<Department[]>([])
const departmentTree = ref<Department[]>([])
// 表单数据
const formData = reactive<CreateDepartmentParams>({
parent_id: null,
dept_name: '',
dept_code: '',
leader: '',
phone: '',
email: '',
status: 1,
sort_order: 0
})
// 当前编辑的部门ID
const currentEditId = ref<number | null>(null)
// 表格列定义
const columns = [
{
title: '部门名称',
dataIndex: 'dept_name',
key: 'dept_name',
width: 200,
fixed: 'left'
},
{
title: '部门编码',
dataIndex: 'dept_code',
key: 'dept_code',
width: 120
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
width: 100
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 120
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
width: 80
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right'
}
]
// 表单验证规则
const formRules = computed(() => ({
dept_name: [
{ required: true, message: '请输入部门名称', trigger: 'blur' },
{ min: 2, max: 50, message: '部门名称长度在2-50个字符', trigger: 'blur' }
],
dept_code: [
{ required: true, message: '请输入部门编码', trigger: 'blur' },
{ min: 2, max: 20, message: '部门编码长度在2-20个字符', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_-]+$/, message: '部门编码只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
leader: [
{ max: 20, message: '负责人姓名不能超过20个字符', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
],
sort_order: [
{ required: true, message: '请输入排序值', trigger: 'blur' },
{ type: 'number', min: 0, max: 9999, message: '排序值范围为0-9999', trigger: 'blur' }
]
}))
// 部门树选择器选项
const departmentTreeOptions = computed(() => {
const addRootOption = (tree: Department[]): Department[] => {
return [
{ dept_id: 0, dept_name: '根部门', children: tree } as Department,
...tree
]
}
return addRootOption(departmentTree.value)
})
// 获取部门树数据
const fetchDepartmentTree = async () => {
try {
loading.value = true
const response = await departmentApi.getDepartmentList()
if (response.success) {
// 后端直接返回树形结构
departmentTree.value = response.data
// 默认展开第一级
expandedKeys.value = departmentTree.value.map(item => item.dept_id)
} else {
message.error(response.message || '获取部门数据失败')
}
} catch (error) {
console.error('获取部门数据失败:', error)
message.error('获取部门数据失败')
} finally {
loading.value = false
}
}
// 刷新数据
const refreshData = () => {
fetchDepartmentTree()
}
// 展开/收起处理
const onExpand = (expanded: boolean, record: Department) => {
if (expanded) {
expandedKeys.value.push(record.dept_id)
} else {
const index = expandedKeys.value.indexOf(record.dept_id)
if (index > -1) {
expandedKeys.value.splice(index, 1)
}
}
}
// 展开全部
const expandAll = () => {
const getAllKeys = (tree: Department[]): number[] => {
let keys: number[] = []
tree.forEach(item => {
keys.push(item.dept_id)
if (item.children && item.children.length > 0) {
keys = keys.concat(getAllKeys(item.children))
}
})
return keys
}
expandedKeys.value = getAllKeys(departmentTree.value)
}
// 收起全部
const collapseAll = () => {
expandedKeys.value = []
}
// 重置表单
const resetForm = () => {
Object.assign(formData, {
parent_id: 0,
dept_name: '',
dept_code: '',
leader: '',
phone: '',
email: '',
status: 1,
sort_order: 0
})
currentEditId.value = null
formRef.value?.resetFields()
}
// 查看部门详情
const handleViewDetail = async (deptId: number) => {
console.log('点击查看详情部门ID:', deptId)
try {
detailLoading.value = true
detailVisible.value = true
console.log('弹窗状态设置为:', detailVisible.value)
const response = await departmentApi.getDepartment(deptId)
if (response.success) {
departmentDetail.value = response.data
console.log('获取部门详情成功:', response.data)
} else {
message.error(response.message || '获取部门详情失败')
}
} catch (error) {
console.error('获取部门详情失败:', error)
message.error('获取部门详情失败')
} finally {
detailLoading.value = false
}
}
// 添加部门
const handleAdd = () => {
resetForm()
modalMode.value = 'add'
modalVisible.value = true
}
// 添加子部门
const handleAddChild = (parent: Department) => {
resetForm()
formData.parent_id = parent.dept_id
modalMode.value = 'add'
modalVisible.value = true
}
// 编辑部门
const handleEdit = (record: Department) => {
console.log('点击编辑部门,部门信息:', record)
resetForm()
Object.assign(formData, {
parent_id: record.parent_id,
dept_name: record.dept_name,
dept_code: record.dept_code,
leader: record.leader,
phone: record.phone,
email: record.email,
status: record.status,
sort_order: record.sort_order
})
currentEditId.value = record.dept_id
modalMode.value = 'edit'
modalVisible.value = true
console.log('编辑弹窗状态设置为:', modalVisible.value)
console.log('表单数据:', formData)
}
// 删除部门
const handleDelete = async (id: number) => {
try {
const response = await departmentApi.deleteDepartment(id)
if (response.success) {
message.success('删除成功')
await fetchDepartmentTree()
} else {
message.error(response.message || '删除失败')
}
} catch (error) {
console.error('删除部门失败:', error)
message.error('删除失败')
}
}
// 弹窗确定
const handleModalOk = async () => {
try {
await formRef.value?.validate()
modalLoading.value = true
// 确保数据类型正确,符合后端要求
const submitData = {
parent_id: Number(formData.parent_id) || 0,
dept_name: String(formData.dept_name || '').trim(),
dept_code: String(formData.dept_code || '').trim(),
leader: String(formData.leader || '').trim(),
phone: String(formData.phone || '').trim(),
email: String(formData.email || '').trim(),
status: Number(formData.status) || 0,
sort_order: Number(formData.sort_order) || 0
}
console.log('提交数据:', submitData)
console.log('操作模式:', modalMode.value)
let response
if (modalMode.value === 'add') {
response = await departmentApi.createDepartment(submitData)
} else {
response = await departmentApi.updateDepartment(currentEditId.value!, submitData)
}
if (response.success) {
message.success(modalMode.value === 'add' ? '添加成功' : '更新成功')
modalVisible.value = false
await fetchDepartmentTree()
} else {
message.error(response.message || (modalMode.value === 'add' ? '添加失败' : '更新失败'))
}
} catch (error) {
console.error('操作失败:', error)
message.error('操作失败')
} finally {
modalLoading.value = false
}
}
// 弹窗取消
const handleModalCancel = () => {
modalVisible.value = false
resetForm()
}
// 组件挂载时获取数据
onMounted(() => {
fetchDepartmentTree()
})
</script>
<style scoped lang="scss">
.department-management {
padding: 0;
background: #f5f5f7;
min-height: 100vh;
// 页面头部 - 苹果风格
.page-header {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
padding: 32px 28px;
margin-bottom: 24px;
border-radius: 0;
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.08);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
.header-content {
display: flex;
align-items: center;
gap: 16px;
.header-icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(59, 130, 246, 0.1);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
transform: scale(1.05);
background: rgba(59, 130, 246, 0.15);
}
.header-icon {
font-size: 24px;
color: #3b82f6;
}
}
.header-text {
.page-title {
font-size: 24px;
font-weight: 600;
color: #1d1d1f;
margin: 0 0 4px 0;
letter-spacing: -0.3px;
line-height: 1.2;
}
.page-description {
color: #86868b;
font-size: 14px;
margin: 0;
font-weight: 400;
}
}
}
}
// 操作栏 - 苹果风格
.action-bar {
margin: 0 28px 24px 28px;
padding: 20px 24px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 0.5px solid rgba(0, 0, 0, 0.08);
display: flex;
justify-content: flex-end;
align-items: center;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: rgba(0, 0, 0, 0.12);
}
.button-section {
display: flex;
gap: 12px;
.action-btn-primary {
border-radius: 12px;
height: 40px;
padding: 0 20px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
&:active {
transform: translateY(0);
}
}
.action-btn-secondary {
border-radius: 12px;
height: 40px;
padding: 0 20px;
font-weight: 500;
border-color: rgba(0, 0, 0, 0.12);
color: #1d1d1f;
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.16);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
// 表格容器 - 苹果风格
.table-container {
margin: 0 28px 24px 28px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 0.5px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: rgba(0, 0, 0, 0.12);
}
:deep(.ant-table) {
background: transparent;
.ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
font-size: 13px;
letter-spacing: -0.1px;
}
.ant-table-tbody > tr {
transition: all 0.2s ease;
&:hover > td {
background: rgba(0, 0, 0, 0.02);
}
> td {
padding: 16px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}
}
}
.dept-name-container {
display: flex;
align-items: center;
.toggle-icon {
margin-right: 8px;
cursor: pointer;
color: #3b82f6;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
color: #2563eb;
background: rgba(59, 130, 246, 0.1);
}
}
.dept-name {
font-weight: 500;
color: #1d1d1f;
font-size: 14px;
}
.children-count {
margin-left: 8px;
color: #86868b;
font-size: 12px;
}
}
// 分页器样式优化 - 苹果风格
:deep(.ant-pagination) {
margin: 20px 24px;
padding: 16px 0;
text-align: center;
background: rgba(0, 0, 0, 0.02);
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
.ant-pagination-total-text {
color: #86868b;
font-size: 13px;
font-weight: 400;
}
.ant-pagination-item {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
margin: 0 4px;
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
}
&.ant-pagination-item-active {
background: #3b82f6;
border-color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
a {
color: white;
font-weight: 600;
}
}
}
.ant-pagination-prev,
.ant-pagination-next {
border-radius: 8px;
margin: 0 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
}
}
.ant-pagination-jump-prev,
.ant-pagination-jump-next {
border-radius: 8px;
}
.ant-select {
margin: 0 8px;
.ant-select-selector {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
}
}
}
.ant-pagination-options-quick-jumper {
margin-left: 16px;
color: #86868b;
font-size: 13px;
input {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
border-color: #3b82f6;
}
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
}
}
}
:deep(.ant-form-item) {
margin-bottom: 16px;
}
// 弹窗样式已由全局样式统一处理
}
// 响应式设计
@media (max-width: 1200px) {
.department-management {
.page-header,
.action-bar,
.table-container {
margin-left: 20px;
margin-right: 20px;
}
}
}
@media (max-width: 768px) {
.department-management {
.page-header {
padding: 24px 16px;
.header-content {
.header-icon-wrapper {
width: 40px;
height: 40px;
.header-icon {
font-size: 20px;
}
}
.header-text {
.page-title {
font-size: 20px;
}
.page-description {
font-size: 13px;
}
}
}
}
.action-bar {
margin: 0 16px 20px 16px;
padding: 16px;
flex-direction: column;
align-items: stretch;
.button-section {
width: 100%;
flex-wrap: wrap;
gap: 8px;
.action-btn-primary,
.action-btn-secondary {
flex: 1;
min-width: 120px;
}
}
}
.table-container {
margin: 0 16px 20px 16px;
border-radius: 12px;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
<template>这是我user_pages</template>

View File

@@ -1,365 +0,0 @@
<template>
<div class="register-container">
<!-- 左侧区域 -->
<div class="left-section">
<div class="welcome-content">
<h1 class="welcome-title">创建账户</h1>
<h2 class="system-name">管理系统模板</h2>
<p class="welcome-description">
填写注册信息开始使用系统
</p>
</div>
</div>
<!-- 右侧注册表单 -->
<div class="right-section">
<div class="register-card">
<div class="register-header">
<h1 class="register-title">{{ $t('register.title') }}</h1>
<p class="register-subtitle">请填写注册信息</p>
</div>
<a-form
:model="form"
:rules="rules"
@finish="handleRegister"
layout="vertical"
class="register-form"
>
<a-form-item
label="用户名"
name="username"
>
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
size="large"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
label="邮箱"
name="email"
>
<a-input
v-model:value="form.email"
placeholder="请输入邮箱"
size="large"
>
<template #prefix>
<MailOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item label="真实姓名" name="real_name">
<a-input v-model:value="form.real_name" placeholder="请输入真实姓名" size="large" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号" size="large" />
</a-form-item>
<a-form-item
label="密码"
name="password"
>
<a-input-password
v-model:value="form.password"
placeholder="请输入密码"
size="large"
>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item
label="确认密码"
name="confirmPassword"
>
<a-input-password
v-model:value="form.confirmPassword"
placeholder="请再次输入密码"
size="large"
>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="loading"
block
class="register-button"
>
注册
</a-button>
</a-form-item>
<div class="login-link">
已有账户
<a @click="goToLogin">立即登录</a>
</div>
</a-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
UserOutlined,
LockOutlined,
MailOutlined
} from '@ant-design/icons-vue'
import { registerUser } from '@/api/auth'
const router = useRouter()
const { t } = useI18n()
const loading = ref(false)
const form = reactive({
username: '',
email: '',
real_name: '',
phone: '',
password: '',
confirmPassword: '',
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
real_name: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (_rule: any, value: string) => {
if (value !== form.password) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
},
trigger: 'blur'
}
],
}
const handleRegister = async () => {
loading.value = true
try {
const payload = {
username: form.username,
password: form.password,
confirm_password: form.confirmPassword,
email: form.email,
phone: form.phone,
real_name: form.real_name,
// 后端未启用验证码时传空串,保持字段兼容
captcha: '',
captcha_id: '',
}
await registerUser(payload as any)
message.success('注册成功')
router.push('/login')
} catch (error: any) {
const detail = error?.response?.data
if (detail) {
const msg = detail.message || detail.detail || '注册失败'
message.error(msg)
} else {
message.error('注册失败')
}
} finally {
loading.value = false
}
}
const goToLogin = () => {
router.push('/login')
}
</script>
<style scoped>
.register-container {
display: flex;
min-height: 100vh;
background: #ffffff;
}
/* 左侧区域 */
.left-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
background: #f9fafb;
}
.welcome-content {
max-width: 500px;
}
.welcome-title {
font-size: 24px;
font-weight: 400;
color: #6b7280;
margin-bottom: 16px;
}
.system-name {
font-size: 48px;
font-weight: 700;
color: #111827;
margin-bottom: 24px;
line-height: 1.2;
}
.welcome-description {
font-size: 18px;
color: #6b7280;
line-height: 1.6;
}
/* 右侧注册表单 */
.right-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
background: #ffffff;
overflow-y: auto;
}
.register-card {
background: #ffffff;
padding: 48px;
border-radius: 12px;
width: 100%;
max-width: 420px;
border: 1px solid #e5e7eb;
}
.register-header {
text-align: center;
margin-bottom: 40px;
}
.register-title {
font-size: 28px;
font-weight: 700;
color: #111827;
margin-bottom: 8px;
}
.register-subtitle {
color: #6b7280;
font-size: 14px;
margin: 0;
}
.register-form {
width: 100%;
}
.login-link {
text-align: center;
margin-top: 24px;
color: #6b7280;
font-size: 14px;
}
.login-link a {
color: #2563eb;
text-decoration: none;
margin-left: 4px;
cursor: pointer;
transition: color 0.2s;
}
.login-link a:hover {
color: #1d4ed8;
}
:deep(.ant-form-item-label > label) {
font-weight: 600;
color: #111827;
font-size: 14px;
}
:deep(.ant-input-affix-wrapper) {
border-radius: 8px;
border: 1px solid #d1d5db;
transition: all 0.2s;
}
:deep(.ant-input-affix-wrapper:focus),
:deep(.ant-input-affix-wrapper-focused) {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
:deep(.ant-input) {
border: none;
font-size: 14px;
}
:deep(.ant-input-prefix) {
color: #9ca3af;
margin-right: 8px;
}
:deep(.ant-btn-primary) {
background: #2563eb;
border-color: #2563eb;
border-radius: 8px;
height: 44px;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
}
:deep(.ant-btn-primary:hover) {
background: #1d4ed8;
border-color: #1d4ed8;
}
:deep(.ant-form-item) {
margin-bottom: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +0,0 @@
<template>
<div class="documents-page">
<a-card title="文档管理" class="documents-card">
<template #extra>
<a-space>
<a-input-search
v-model:value="searchText"
placeholder="搜索文档"
style="width: 200px"
@search="handleSearch"
/>
<a-button type="primary" @click="showUploadModal = true">
<UploadOutlined />
上传文档
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="filteredDocuments"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-space>
<component :is="getFileIcon(record.type)" />
<a @click="previewDocument(record)">{{ record.name }}</a>
</a-space>
</template>
<template v-else-if="column.key === 'size'">
{{ formatFileSize(record.size) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="downloadDocument(record)">下载</a-button>
<a-button type="link" size="small" @click="shareDocument(record)">分享</a-button>
<a-button type="link" size="small" danger @click="deleteDocument(record.id)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 上传文档模态框 -->
<a-modal
v-model:open="showUploadModal"
title="上传文档"
@ok="handleUpload"
@cancel="resetUploadForm"
>
<a-form :model="uploadForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="文档名称" name="name">
<a-input v-model:value="uploadForm.name" placeholder="可选,默认使用文件名" />
</a-form-item>
<a-form-item label="文档描述" name="description">
<a-textarea v-model:value="uploadForm.description" :rows="3" />
</a-form-item>
<a-form-item label="选择文件" name="file" :rules="[{ required: true, message: '请选择文件' }]">
<a-upload
v-model:file-list="fileList"
:before-upload="beforeUpload"
:remove="handleRemove"
accept=".pdf,.doc,.docx,.txt,.md"
>
<a-button>
<UploadOutlined />
选择文件
</a-button>
</a-upload>
</a-form-item>
</a-form>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined, FileTextOutlined, FilePdfOutlined, FileWordOutlined } from '@ant-design/icons-vue'
interface Document {
id: number
name: string
description: string
type: string
size: number
created_at: string
updated_at: string
}
const columns = [
{ title: '文档名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '大小', dataIndex: 'size', key: 'size', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150 },
{ title: '操作', key: 'action', width: 200 }
]
const documents = ref<Document[]>([
{
id: 1,
name: '项目需求文档.pdf',
description: '项目的详细需求说明',
type: 'pdf',
size: 2048576,
created_at: '2024-01-01',
updated_at: '2024-01-01'
},
{
id: 2,
name: '技术方案.docx',
description: '技术实现方案文档',
type: 'docx',
size: 1024000,
created_at: '2024-01-02',
updated_at: '2024-01-02'
}
])
const searchText = ref('')
const showUploadModal = ref(false)
const fileList = ref([])
const uploadForm = ref({
name: '',
description: ''
})
const filteredDocuments = computed(() => {
if (!searchText.value) {
return documents.value
}
return documents.value.filter(doc =>
doc.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
doc.description.toLowerCase().includes(searchText.value.toLowerCase())
)
})
const getFileIcon = (type: string) => {
const iconMap: Record<string, any> = {
pdf: FilePdfOutlined,
doc: FileWordOutlined,
docx: FileWordOutlined,
txt: FileTextOutlined,
md: FileTextOutlined
}
return iconMap[type] || FileTextOutlined
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const handleSearch = () => {
// 搜索逻辑已在 computed 中实现
}
const previewDocument = (doc: Document) => {
message.info(`预览文档: ${doc.name}`)
// 这里可以实现文档预览功能
}
const downloadDocument = (doc: Document) => {
message.success(`开始下载: ${doc.name}`)
// 这里可以实现文档下载功能
}
const shareDocument = (doc: Document) => {
message.info(`分享文档: ${doc.name}`)
// 这里可以实现文档分享功能
}
const deleteDocument = (id: number) => {
const index = documents.value.findIndex(doc => doc.id === id)
if (index > -1) {
documents.value.splice(index, 1)
message.success('文档删除成功')
}
}
const beforeUpload = (file: any) => {
const isValidType = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', 'text/markdown'].includes(file.type)
if (!isValidType) {
message.error('只能上传 PDF、Word、TXT、MD 格式的文件!')
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB!')
return false
}
return false // 阻止自动上传
}
const handleRemove = () => {
fileList.value = []
}
const handleUpload = () => {
if (fileList.value.length === 0) {
message.error('请选择文件')
return
}
const file = fileList.value[0] as any
const newDocument: Document = {
id: Date.now(),
name: uploadForm.value.name || file.name,
description: uploadForm.value.description,
type: file.name.split('.').pop() || 'unknown',
size: file.size,
created_at: new Date().toISOString().split('T')[0],
updated_at: new Date().toISOString().split('T')[0]
}
documents.value.unshift(newDocument)
message.success('文档上传成功')
resetUploadForm()
}
const resetUploadForm = () => {
showUploadModal.value = false
fileList.value = []
uploadForm.value = {
name: '',
description: ''
}
}
onMounted(() => {
// 这里可以调用获取文档列表的API
})
</script>
<style scoped lang="scss">
.documents-page {
padding: 24px;
.documents-card {
:deep(.ant-table-tbody > tr > td) {
vertical-align: top;
}
}
}
</style>

View File

@@ -1,625 +0,0 @@
<template>
<div class="knowledge-center">
<div class="page-header">
<h1 class="page-title">
<BookOutlined class="title-icon" />
知识库中心
</h1>
<p class="page-description">探索智能知识提升工作效率</p>
</div>
<!-- 统计卡片 -->
<div class="stats-section">
<div class="stat-card">
<div class="stat-icon">
<FileTextOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ pagination.total }}</div>
<div class="stat-label">文章总数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<BookOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ categoryTree.length }}</div>
<div class="stat-label">分类数量</div>
</div>
</div>
</div>
<div class="content-row">
<a-row :gutter="[16, 16]">
<!-- 左侧分类树 -->
<a-col :xs="24" :md="6">
<div class="category-panel">
<div class="panel-header">
<div class="panel-icon">
<BookOutlined />
</div>
<h3 class="panel-title">知识分类</h3>
</div>
<div class="panel-content">
<a-spin :spinning="categoryLoading">
<a-tree
:tree-data="categoryTree"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
default-expand-all
@select="onCategorySelect"
class="tech-tree"
/>
</a-spin>
</div>
</div>
</a-col>
<!-- 右侧文章列表 -->
<a-col :xs="24" :md="18">
<div class="articles-panel">
<div class="panel-header">
<div class="header-left">
<div class="panel-icon">
<FileTextOutlined />
</div>
<div class="title-group">
<h3 class="panel-title">知识库</h3>
<span class="panel-subtitle">发现更多精彩内容</span>
</div>
</div>
<div class="search-section">
<a-input-search
v-model:value="searchText"
placeholder="搜索文章标题或标签..."
class="tech-search"
@search="handleSearchImmediate"
@input="handleSearch"
allow-clear
:loading="loading"
>
<template #prefix>
<SearchOutlined class="search-icon" />
</template>
<template #enterButton>
<a-button type="primary" class="search-btn">
<SearchOutlined />
</a-button>
</template>
</a-input-search>
</div>
</div>
<div class="panel-content">
<a-spin :spinning="loading">
<div class="articles-grid" v-if="articleList.length > 0">
<div
v-for="(item, index) in articleList"
:key="item.id"
class="article-card"
@click="openDetail(item.id)"
>
<div class="card-header">
<div class="article-status">
<a-tag color="success" class="status-tag">
<CheckCircleOutlined />
已发布
</a-tag>
</div>
<div class="article-views" v-if="item.view_count">
<EyeOutlined />
<span>{{ item.view_count }}</span>
</div>
</div>
<div class="card-body">
<h4 class="article-title">{{ item.title }}</h4>
<p class="article-summary" v-if="item.summary">{{ item.summary }}</p>
</div>
<div class="card-footer">
<div class="article-meta">
<div class="meta-item">
<BookOutlined />
<span>{{ item.category_name }}</span>
</div>
<div class="meta-item">
<UserOutlined />
<span>{{ item.author_name }}</span>
</div>
<div class="meta-item">
<ClockCircleOutlined />
<span>{{ formatDate(item.published_at || item.updated_at) }}</span>
</div>
</div>
<div class="read-more">
<ReadOutlined />
<span>阅读全文</span>
</div>
</div>
</div>
</div>
<a-empty v-else description="暂无文章数据" class="empty-state">
<template #image>
<FileSearchOutlined style="font-size: 64px; color: #d9d9d9;" />
</template>
</a-empty>
</a-spin>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="pagination.total > 0">
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-size-changer="true"
:show-quick-jumper="true"
:show-total="(total, range) => `${range[0]}-${range[1]} 条,共 ${total}`"
@change="pagination.onChange"
class="tech-pagination"
/>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
BookOutlined,
FileTextOutlined,
EyeOutlined,
ReadOutlined,
SearchOutlined,
CheckCircleOutlined,
UserOutlined,
ClockCircleOutlined,
FileSearchOutlined
} from '@ant-design/icons-vue'
import { knowledgeApi, type KnowledgeArticleListItem, type KnowledgeArticleDetail, type KnowledgeCategory } from '@/api/knowledge'
const loading = ref(false)
const categoryLoading = ref(false)
const searchText = ref('')
const router = useRouter()
const categoryTree = ref<KnowledgeCategory[]>([])
const selectedCategoryId = ref<number | undefined>(undefined)
const articleList = ref<KnowledgeArticleListItem[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
onChange: (page: number, pageSize: number) => {
pagination.current = page
pagination.pageSize = pageSize
fetchArticles()
}
})
// 详情页改为独立路由展示
const fetchCategories = async () => {
try {
categoryLoading.value = true
const res = await knowledgeApi.getCategoryTree()
categoryTree.value = (res.data || []).filter((c: any) => c.is_active !== false)
} catch (e) {
console.error(e)
} finally {
categoryLoading.value = false
}
}
const fetchArticles = async () => {
try {
loading.value = true
const res = await knowledgeApi.getArticles({
page: pagination.current,
page_size: pagination.pageSize,
title: searchText.value || undefined,
category_id: selectedCategoryId.value,
status: 'published'
})
const data = res.data
articleList.value = data?.list || []
pagination.total = data?.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearchImmediate = () => {
pagination.current = 1
fetchArticles()
}
const handleSearch = () => {
// 防抖可选,这里直接刷新列表
pagination.current = 1
fetchArticles()
}
const onCategorySelect = (keys: (string | number)[]) => {
selectedCategoryId.value = (keys[0] as number) || undefined
pagination.current = 1
fetchArticles()
}
const formatDate = (dateString: string) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const openDetail = async (id: number) => {
try {
const res = await knowledgeApi.getArticle(id)
const detail = res.data
if (detail.status !== 'published') {
message.warning('该文章未发布,无法查看')
return
}
router.push(`/user/knowledge/${id}`)
} catch (e) {
console.error(e)
}
}
onMounted(() => {
fetchCategories()
fetchArticles()
})
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.knowledge-center {
padding: 24px;
background: var(--theme-page-bg, #f5f5f5);
min-height: 100vh;
.page-header {
margin-bottom: 24px;
text-align: center;
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.title-icon {
color: var(--theme-primary, #3b82f6);
}
}
.page-description {
color: var(--theme-text-secondary, #64748b);
font-size: 1.1rem;
margin: 0;
}
}
// 统计卡片
.stats-section {
max-width: 1200px;
margin: 0 auto 24px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
.stat-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
align-items: center;
gap: 20px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.stat-content {
flex: 1;
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
color: var(--theme-text-secondary, #6b7280);
font-size: 14px;
}
}
}
}
.content-row {
max-width: 1200px;
margin: 0 auto;
}
// 分类面板
.category-panel {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
flex-direction: column;
height: 100%;
min-height: 500px;
.panel-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
flex-shrink: 0;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
.panel-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.panel-title {
color: var(--theme-text-primary, #1e293b);
font-size: 16px;
font-weight: 600;
margin: 0;
}
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 20px;
.tech-tree {
:deep(.ant-tree-node-content-wrapper) {
border-radius: 6px;
padding: 6px 10px;
&:hover {
background: var(--theme-content-bg, #eff6ff);
}
&.ant-tree-node-selected {
background: var(--theme-content-bg, #eff6ff);
color: var(--theme-primary, #2563eb);
}
}
}
}
}
// 文章面板
.articles-panel {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
.panel-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.panel-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.title-group {
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
margin: 0 0 4px 0;
}
.panel-subtitle {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
}
}
}
.search-section {
.tech-search {
width: 300px;
}
}
}
.panel-content {
padding: 24px;
.articles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 16px;
margin-bottom: 24px;
.article-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
border: 1px solid var(--theme-card-border, #e5e7eb);
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: var(--theme-primary, #3b82f6);
}
.card-header {
padding: 20px 20px 0;
display: flex;
justify-content: space-between;
align-items: center;
.article-status {
.status-tag {
border-radius: 20px;
font-weight: 500;
padding: 4px 12px;
display: flex;
align-items: center;
gap: 6px;
}
}
.article-views {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
font-weight: 500;
}
}
.card-body {
padding: 16px 20px;
.article-title {
font-size: 18px;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin: 0 0 12px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-summary {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
line-height: 1.6;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.card-footer {
padding: 0 20px 20px;
display: flex;
justify-content: space-between;
align-items: center;
.article-meta {
display: flex;
flex-direction: column;
gap: 8px;
.meta-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-secondary, #64748b);
font-size: 13px;
font-weight: 500;
}
}
.read-more {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-text-secondary, #94a3b8);
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
}
}
}
}
.empty-state {
padding: 60px 20px;
text-align: center;
:deep(.ant-empty-description) {
color: var(--theme-text-secondary, #64748b);
font-size: 16px;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--theme-card-border, #e5e7eb);
}
}
}
}
</style>

View File

@@ -1,671 +0,0 @@
<template>
<div class="knowledge-detail">
<!-- 科技风头部导航 -->
<div class="tech-header animate-fade-in-up">
<div class="header-bg">
<div class="tech-pattern"></div>
<div class="gradient-overlay"></div>
</div>
<div class="header-content">
<div class="nav-actions">
<a-button @click="goBack" class="nav-btn back-btn">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<a-button @click="copyLink" class="nav-btn copy-btn">
<template #icon><LinkOutlined /></template>
复制链接
</a-button>
<a-button @click="printPage" class="nav-btn print-btn">
<template #icon><PrinterOutlined /></template>
打印文章
</a-button>
</div>
</div>
</div>
<!-- 文章内容区域 -->
<div class="article-container">
<div class="article-wrapper animate-fade-in-up" style="animation-delay: 0.2s;">
<!-- 文章头部信息 -->
<div class="article-header">
<div class="article-status">
<a-tag color="success" class="status-badge" v-if="detail?.status === 'published'">
<CheckCircleOutlined />
已发布
</a-tag>
</div>
<h1 class="article-title">{{ detail?.title || '加载中...' }}</h1>
<div class="article-meta">
<div class="meta-grid">
<div class="meta-item">
<div class="meta-icon">
<BookOutlined />
</div>
<div class="meta-content">
<span class="meta-label">分类</span>
<span class="meta-value">{{ detail?.category_name }}</span>
</div>
</div>
<div class="meta-item">
<div class="meta-icon">
<UserOutlined />
</div>
<div class="meta-content">
<span class="meta-label">作者</span>
<span class="meta-value">{{ detail?.author_name }}</span>
</div>
</div>
<div class="meta-item">
<div class="meta-icon">
<CalendarOutlined />
</div>
<div class="meta-content">
<span class="meta-label">发布时间</span>
<span class="meta-value">{{ formatDate(detail?.published_at || detail?.created_at) }}</span>
</div>
</div>
<div class="meta-item" v-if="detail?.view_count">
<div class="meta-icon">
<EyeOutlined />
</div>
<div class="meta-content">
<span class="meta-label">浏览次数</span>
<span class="meta-value">{{ detail.view_count }}</span>
</div>
</div>
</div>
</div>
<div class="article-tags" v-if="detail?.tags_list?.length">
<div class="tags-label">标签</div>
<div class="tags-list">
<a-tag
v-for="tag in detail.tags_list"
:key="tag"
class="tech-tag"
>
<TagOutlined />
{{ tag }}
</a-tag>
</div>
</div>
</div>
<!-- 文章内容 -->
<div class="article-content">
<a-spin :spinning="loading" size="large">
<div class="content-wrapper" v-html="detail?.content"></div>
</a-spin>
</div>
<!-- 文章底部操作 -->
<div class="article-footer">
<div class="footer-actions">
<a-button @click="goBack" class="action-btn">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<a-button @click="copyLink" class="action-btn">
<template #icon><ShareAltOutlined /></template>
分享文章
</a-button>
<a-button @click="printPage" class="action-btn">
<template #icon><PrinterOutlined /></template>
打印
</a-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined,
LinkOutlined,
PrinterOutlined,
CheckCircleOutlined,
BookOutlined,
UserOutlined,
CalendarOutlined,
EyeOutlined,
TagOutlined,
ShareAltOutlined
} from '@ant-design/icons-vue'
import { knowledgeApi, type KnowledgeArticleDetail } from '@/api/knowledge'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<KnowledgeArticleDetail | null>(null)
const loadDetail = async () => {
try {
loading.value = true
const id = Number(route.params.id)
if (!id) {
message.error('参数错误')
goBack()
return
}
const res = await knowledgeApi.getArticle(id)
const d = res.data
if (d.status !== 'published') {
message.warning('该文章未发布,无法查看')
goBack()
return
}
detail.value = d
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const formatDate = (dateString: string) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const goBack = () => router.push({ path: '/dashboard', query: { menu: 'knowledge-center' } })
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href)
message.success('链接已复制到剪贴板')
} catch {
message.error('复制失败,请手动复制链接')
}
}
const printPage = () => window.print()
onMounted(loadDetail)
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.knowledge-detail {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
// 科技风头部导航
.tech-header {
position: relative;
padding: 24px 0;
margin-bottom: 32px;
overflow: hidden;
.header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
.tech-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 25% 25%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(37, 99, 235, 0.1) 0%, transparent 50%);
animation: patternFloat 15s ease-in-out infinite;
}
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
}
}
.header-content {
position: relative;
z-index: 2;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
.nav-actions {
display: flex;
gap: 16px;
justify-content: center;
.nav-btn {
border-radius: 12px;
height: 44px;
padding: 0 20px;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 2px solid transparent;
&.back-btn {
background: rgba(255, 255, 255, 0.1);
color: white;
border-color: rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
}
&.copy-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: none;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4);
}
}
&.print-btn {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
border: none;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(245, 158, 11, 0.4);
}
}
}
}
}
}
// 文章容器
.article-container {
max-width: 1000px;
margin: 0 auto;
padding: 0 24px;
.article-wrapper {
background: white;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(59, 130, 246, 0.1);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
}
// 文章头部
.article-header {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
padding: 40px;
border-bottom: 1px solid #e2e8f0;
.article-status {
margin-bottom: 24px;
.status-badge {
border-radius: 20px;
font-weight: 600;
padding: 8px 16px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
}
.article-title {
font-size: 2.5rem;
font-weight: 800;
color: #1e293b;
margin: 0 0 32px 0;
line-height: 1.2;
text-align: center;
}
.article-meta {
margin-bottom: 32px;
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
.meta-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: white;
border-radius: 16px;
border: 1px solid #e2e8f0;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #3b82f6;
}
.meta-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
}
.meta-content {
display: flex;
flex-direction: column;
gap: 4px;
.meta-label {
font-size: 12px;
color: #64748b;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-size: 16px;
color: #1e293b;
font-weight: 600;
}
}
}
}
}
.article-tags {
.tags-label {
font-size: 14px;
color: #64748b;
font-weight: 600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.tech-tag {
border-radius: 20px;
padding: 8px 16px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%);
color: #0277bd;
border: 1px solid #81d4fa;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(2, 119, 189, 0.3);
}
}
}
}
}
// 文章内容
.article-content {
padding: 40px;
.content-wrapper {
font-size: 16px;
line-height: 1.8;
color: #374151;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
// 内容样式优化
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
color: #1e293b;
font-weight: 700;
margin: 32px 0 16px 0;
line-height: 1.3;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(h1) { font-size: 2rem; }
:deep(h2) { font-size: 1.75rem; }
:deep(h3) { font-size: 1.5rem; }
:deep(h4) { font-size: 1.25rem; }
:deep(p) {
margin: 16px 0;
text-align: justify;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(ul), :deep(ol) {
margin: 16px 0;
padding-left: 24px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
li {
margin: 8px 0;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
}
:deep(blockquote) {
border-left: 4px solid #3b82f6;
background: #f8fafc;
padding: 16px 24px;
margin: 24px 0;
border-radius: 0 8px 8px 0;
font-style: italic;
color: #64748b;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(code) {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #e11d48;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(pre) {
background: #1e293b;
color: #e2e8f0;
padding: 24px;
border-radius: 12px;
overflow-x: auto;
margin: 24px 0;
code {
background: transparent;
color: inherit;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
margin: 24px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
table-layout: fixed;
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
th {
background: #f8fafc;
font-weight: 600;
color: #1e293b;
}
tr:hover {
background: #f8fafc;
}
}
}
}
// 文章底部
.article-footer {
background: #f8fafc;
padding: 32px 40px;
border-top: 1px solid #e2e8f0;
.footer-actions {
display: flex;
justify-content: center;
gap: 16px;
.action-btn {
border-radius: 12px;
height: 44px;
padding: 0 24px;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
}
}
}
}
}
}
// 科技风动画
@keyframes patternFloat {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
33% { transform: translate(30px, -30px) rotate(120deg); }
66% { transform: translate(-20px, 20px) rotate(240deg); }
}
// 响应式设计
@media (max-width: 768px) {
.knowledge-detail {
.tech-header {
padding: 16px 0;
.header-content .nav-actions {
flex-direction: column;
align-items: center;
gap: 12px;
.nav-btn {
width: 200px;
}
}
}
.article-container {
padding: 0 16px;
.article-wrapper {
.article-header {
padding: 24px;
.article-title {
font-size: 2rem;
}
.article-meta .meta-grid {
grid-template-columns: 1fr;
gap: 16px;
.meta-item {
padding: 16px;
}
}
}
.article-content {
padding: 24px;
}
.article-footer {
padding: 24px;
.footer-actions {
flex-direction: column;
align-items: center;
.action-btn {
width: 200px;
}
}
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,318 +0,0 @@
<template>
<div class="messages-page">
<a-card title="消息中心" class="messages-card">
<template #extra>
<a-space>
<a-button @click="markAllAsRead" :disabled="unreadCount === 0">
全部标记为已读
</a-button>
<a-badge :count="unreadCount">
<BellOutlined style="font-size: 16px;" />
</a-badge>
</a-space>
</template>
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<a-tab-pane key="all" tab="全部消息">
<a-list
:data-source="filteredMessages"
:pagination="{ pageSize: 10 }"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item
:class="{ 'unread-message': !item.read }"
@click="markAsRead(item)"
>
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: getTypeColor(item.type) }">
<component :is="getTypeIcon(item.type)" />
</a-avatar>
</template>
<template #title>
<div class="message-title">
<span>{{ item.title }}</span>
<a-tag v-if="!item.read" color="red" size="small">未读</a-tag>
</div>
</template>
<template #description>
<div class="message-content">
<p>{{ item.content }}</p>
<span class="message-time">{{ formatTime(item.created_at) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane key="unread" tab="未读消息">
<a-list
:data-source="unreadMessages"
:pagination="{ pageSize: 10 }"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item @click="markAsRead(item)">
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: getTypeColor(item.type) }">
<component :is="getTypeIcon(item.type)" />
</a-avatar>
</template>
<template #title>
<div class="message-title">
<span>{{ item.title }}</span>
<a-tag color="red" size="small">未读</a-tag>
</div>
</template>
<template #description>
<div class="message-content">
<p>{{ item.content }}</p>
<span class="message-time">{{ formatTime(item.created_at) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane key="system" tab="系统通知">
<a-list
:data-source="systemMessages"
:pagination="{ pageSize: 10 }"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item
:class="{ 'unread-message': !item.read }"
@click="markAsRead(item)"
>
<a-list-item-meta>
<template #avatar>
<a-avatar style="background-color: #1890ff">
<NotificationOutlined />
</a-avatar>
</template>
<template #title>
<div class="message-title">
<span>{{ item.title }}</span>
<a-tag v-if="!item.read" color="red" size="small">未读</a-tag>
</div>
</template>
<template #description>
<div class="message-content">
<p>{{ item.content }}</p>
<span class="message-time">{{ formatTime(item.created_at) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
BellOutlined,
NotificationOutlined,
UserOutlined,
WarningOutlined,
CheckCircleOutlined
} from '@ant-design/icons-vue'
import dayjs from 'dayjs'
interface Message {
id: number
title: string
content: string
type: 'system' | 'user' | 'warning' | 'success'
read: boolean
created_at: string
}
const activeTab = ref('all')
const messages = ref<Message[]>([
{
id: 1,
title: '系统维护通知',
content: '系统将于今晚22:00-24:00进行维护期间可能无法正常访问请提前做好准备。',
type: 'system',
read: false,
created_at: '2024-01-15T10:30:00'
},
{
id: 2,
title: '密码即将过期',
content: '您的密码将在7天后过期请及时修改密码以确保账户安全。',
type: 'warning',
read: false,
created_at: '2024-01-14T15:20:00'
},
{
id: 3,
title: '项目审核通过',
content: '恭喜!您提交的项目"用户管理系统"已通过审核,可以开始正式开发。',
type: 'success',
read: true,
created_at: '2024-01-13T09:15:00'
},
{
id: 4,
title: '新用户注册',
content: '有新用户注册了您的应用,请及时查看用户信息。',
type: 'user',
read: false,
created_at: '2024-01-12T14:45:00'
}
])
const filteredMessages = computed(() => {
switch (activeTab.value) {
case 'unread':
return messages.value.filter(msg => !msg.read)
case 'system':
return messages.value.filter(msg => msg.type === 'system')
default:
return messages.value
}
})
const unreadMessages = computed(() => {
return messages.value.filter(msg => !msg.read)
})
const systemMessages = computed(() => {
return messages.value.filter(msg => msg.type === 'system')
})
const unreadCount = computed(() => {
return messages.value.filter(msg => !msg.read).length
})
const getTypeColor = (type: Message['type']) => {
const colors = {
system: '#1890ff',
user: '#52c41a',
warning: '#faad14',
success: '#52c41a'
}
return colors[type]
}
const getTypeIcon = (type: Message['type']) => {
const icons = {
system: NotificationOutlined,
user: UserOutlined,
warning: WarningOutlined,
success: CheckCircleOutlined
}
return icons[type]
}
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm')
}
const markAsRead = (msg: Message) => {
if (!msg.read) {
msg.read = true
message.success('消息已标记为已读')
}
}
const markAllAsRead = () => {
const unreadMessages = messages.value.filter(msg => !msg.read)
unreadMessages.forEach(msg => {
msg.read = true
})
if (unreadMessages.length > 0) {
message.success(`已将 ${unreadMessages.length} 条消息标记为已读`)
}
}
const deleteMessage = (id: number) => {
const index = messages.value.findIndex(msg => msg.id === id)
if (index > -1) {
messages.value.splice(index, 1)
message.success('消息删除成功')
}
}
const handleTabChange = (key: string) => {
activeTab.value = key
}
onMounted(() => {
// 这里可以调用获取消息列表的API
})
</script>
<style scoped lang="scss">
.messages-page {
padding: 24px;
.messages-card {
.unread-message {
background-color: #f6ffed;
border-left: 3px solid #52c41a;
}
.message-title {
display: flex;
align-items: center;
justify-content: space-between;
span {
font-weight: 500;
}
}
.message-content {
p {
margin: 0 0 8px 0;
color: #666;
}
.message-time {
font-size: 12px;
color: #999;
}
}
:deep(.ant-list-item) {
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #f5f5f5;
}
}
}
}
</style>

View File

@@ -1,842 +0,0 @@
<template>
<div class="notice-center">
<div class="page-header">
<h1 class="page-title">
<BellOutlined class="title-icon" />
通知中心
</h1>
<p class="page-description">管理您的通知和消息</p>
</div>
<!-- 操作栏 -->
<div class="action-bar">
<a-space>
<a-button
type="primary"
:disabled="unreadIds.length === 0"
@click="markAllRead"
:loading="batchLoading"
>
<template #icon>
<CheckCircleOutlined />
</template>
全部标记为已读
</a-button>
<a-button
@click="refreshList"
:loading="loadingList"
>
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon total">
<BellOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.total_count }}</div>
<div class="stat-label">总消息</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon unread">
<ExclamationCircleOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.unread_count }}</div>
<div class="stat-label">未读消息</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon read">
<CheckCircleOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.read_count }}</div>
<div class="stat-label">已读消息</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon starred">
<StarOutlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.starred_count }}</div>
<div class="stat-label">收藏消息</div>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-container">
<div class="content-wrapper">
<!-- 标签页 -->
<div class="tabs-container">
<a-tabs v-model:activeKey="activeTab" @change="onTabChange" class="tech-tabs">
<a-tab-pane key="all" tab="全部消息" />
<a-tab-pane key="unread" tab="未读消息" />
<a-tab-pane key="starred" tab="收藏消息" />
<a-tab-pane key="system" tab="系统通知" />
</a-tabs>
</div>
<!-- 空状态提示 -->
<div v-if="!loadingList && notices.length === 0" class="empty-state">
<div class="empty-icon">
<BellOutlined />
</div>
<h3 class="empty-title">暂无可见通知</h3>
<p class="empty-description">{{ emptyHint }}</p>
</div>
<!-- 通知列表 -->
<div v-else class="notices-container">
<a-list :data-source="displayedNotices" :loading="loadingList" item-layout="vertical" class="tech-list">
<template #renderItem="{ item }">
<a-list-item class="notice-item">
<div class="notice-card" :class="{ 'unread': !item.is_read, 'starred': item.is_starred }">
<div class="notice-header">
<div class="notice-icon" :class="iconClass(item.notice_type_display)">
<component :is="getIconComponent(item.notice_type_display)" />
</div>
<div class="notice-content">
<div class="title-line">
<h3 class="notice-title">{{ item.title }}</h3>
<div class="notice-badges">
<a-tag v-if="item.is_top" color="red" class="top-tag">置顶</a-tag>
<a-tag :color="item.is_read ? 'green' : 'orange'" class="status-tag">
{{ item.is_read ? '已读' : '未读' }}
</a-tag>
</div>
</div>
<p class="notice-desc">{{ itemDesc(item) }}</p>
<div class="notice-meta">
<span class="publish-time">
<ClockCircleOutlined />
{{ item.publish_time }}
</span>
<div class="notice-actions">
<a-button type="link" @click="viewDetail(item)" class="action-link">
<EyeOutlined />
查看详情
</a-button>
<a-button
type="link"
:disabled="item.is_read"
@click="markRead(item)"
class="action-link"
>
<CheckCircleOutlined />
标记已读
</a-button>
<a-button type="link" @click="toggleStar(item)" class="action-link">
<StarOutlined />
{{ item.is_starred ? '取消收藏' : '收藏' }}
</a-button>
</div>
</div>
</div>
</div>
</div>
</a-list-item>
</template>
</a-list>
</div>
<!-- 分页 -->
<div class="pagination-container">
<a-pagination
:current="currentPage"
:pageSize="pageSize"
:total="totalCount"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageSizeChange"
class="tech-pagination"
/>
</div>
</div>
</div>
<!-- 详情模态框 -->
<a-modal
v-model:visible="detailVisible"
title="通知详情"
width="720px"
@cancel="closeDetail"
:footer="null"
class="detail-modal"
>
<a-spin :spinning="detailLoading">
<div class="detail-content">
<a-descriptions :column="1" bordered class="tech-descriptions">
<a-descriptions-item label="标题">
<span class="detail-title">{{ detailData?.title }}</span>
</a-descriptions-item>
<a-descriptions-item label="内容">
<div class="detail-text">{{ detailData?.content }}</div>
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(detailData?.notice_type_display)">
{{ detailData?.notice_type_display }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="优先级">
<a-tag :color="getPriorityColor(detailData?.priority_display)">
{{ detailData?.priority_display }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发布时间">
<span class="detail-time">{{ detailData?.publish_time }}</span>
</a-descriptions-item>
<a-descriptions-item label="过期时间">
<span class="detail-time">{{ detailData?.expire_time || '永久有效' }}</span>
</a-descriptions-item>
<a-descriptions-item label="状态信息">
<div class="status-info">
<a-tag :color="detailData?.is_top ? 'red' : 'default'">
{{ detailData?.is_top ? '置顶' : '普通' }}
</a-tag>
<a-tag :color="detailData?.is_starred ? 'gold' : 'default'">
{{ detailData?.is_starred ? '已收藏' : '未收藏' }}
</a-tag>
<a-tag :color="detailData?.is_read ? 'green' : 'orange'">
{{ detailData?.is_read ? '已读' : '未读' }}
</a-tag>
</div>
</a-descriptions-item>
</a-descriptions>
</div>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import {
BellOutlined,
CheckCircleOutlined,
ReloadOutlined,
ExclamationCircleOutlined,
StarOutlined,
ClockCircleOutlined,
EyeOutlined,
SettingOutlined,
WarningOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue'
import { noticeUserApi, type UserNoticeListItem, type UserNoticeListData, type UserNoticeDetailData } from '@/api/notice_user'
const loadingList = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const notices = ref<UserNoticeListItem[]>([])
const totalCount = ref(0)
const batchLoading = ref(false)
const activeTab = ref<'all' | 'unread' | 'starred' | 'system'>('all')
const statistics = reactive({
total_count: 0,
unread_count: 0,
read_count: 0,
starred_count: 0,
})
const unreadIds = computed(() => notices.value.filter(n => !n.is_read).map(n => n.notice))
const displayedNotices = computed(() => {
let arr = notices.value
if (activeTab.value === 'unread') {
arr = arr.filter(n => !n.is_read)
} else if (activeTab.value === 'starred') {
arr = arr.filter(n => n.is_starred)
} else if (activeTab.value === 'system') {
arr = arr.filter(n => n.notice_type_display === '系统通知')
}
return arr
})
// 空状态提示文案
const emptyHint = computed(() => {
if (activeTab.value === 'starred') {
return '您还没有收藏任何消息。点击消息卡片中的“收藏”按钮可以收藏消息。'
} else if (activeTab.value === 'unread') {
return '您没有未读消息。所有消息都已阅读完毕。'
} else if (activeTab.value === 'system') {
return '暂无系统通知。'
}
return '可能原因:通知未发布或已过期。请联系管理员在“通知管理”中点击“发布”,或调整过期时间后再试。'
})
const fetchStatistics = async () => {
try {
const res = await noticeUserApi.statistics()
if (res.success) {
const s = res.data
statistics.total_count = s.total_count || 0
statistics.unread_count = s.unread_count || 0
statistics.starred_count = s.starred_count || 0
statistics.read_count = s.read_count || (s.total_count - s.unread_count)
}
} catch (e) {
// 静默统计失败
}
}
const fetchList = async () => {
loadingList.value = true
try {
const res = await noticeUserApi.list({ page: currentPage.value, page_size: pageSize.value })
if (res.success) {
notices.value = res.data.notices || []
totalCount.value = res.data.pagination?.total_count || 0
}
} catch (e: any) {
message.error(e?.message || '获取通知列表失败')
} finally {
loadingList.value = false
}
}
const refreshList = () => {
fetchList()
fetchStatistics()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchList()
}
const handlePageSizeChange = (_: number, size: number) => {
pageSize.value = size
currentPage.value = 1
fetchList()
}
const onTabChange = () => {
// 标签切换后回到第一页,并刷新当前页数据与统计
currentPage.value = 1
fetchList()
fetchStatistics()
}
// 左侧图标样式映射
const iconClass = (type: string) => {
switch (type) {
case '系统通知':
return 'icon-system'
case '安全提醒':
case '告警':
case '风险提示':
return 'icon-warning'
case '业务通知':
case '普通通知':
case '成功':
return 'icon-success'
default:
return 'icon-default'
}
}
// 获取图标组件
const getIconComponent = (type: string) => {
switch (type) {
case '系统通知':
return SettingOutlined
case '安全提醒':
case '告警':
case '风险提示':
return WarningOutlined
case '业务通知':
case '普通通知':
case '成功':
return CheckCircleOutlined
default:
return InfoCircleOutlined
}
}
// 获取类型颜色
const getTypeColor = (type?: string) => {
switch (type) {
case '系统通知':
return 'blue'
case '安全提醒':
case '告警':
case '风险提示':
return 'red'
case '业务通知':
case '普通通知':
case '成功':
return 'green'
default:
return 'default'
}
}
// 获取优先级颜色
const getPriorityColor = (priority?: string) => {
switch (priority) {
case '高':
return 'red'
case '中':
return 'orange'
case '低':
return 'green'
default:
return 'default'
}
}
// 列表摘要描述(依据文档字段,列表不含正文,展示类型与优先级)
const itemDesc = (item: UserNoticeListItem) => {
const parts: string[] = []
if (item.notice_type_display) parts.push(item.notice_type_display)
if (item.priority_display) parts.push(item.priority_display)
return parts.join(' · ')
}
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<UserNoticeDetailData | null>(null)
const viewDetail = async (record: UserNoticeListItem) => {
detailVisible.value = true
detailLoading.value = true
try {
const res = await noticeUserApi.detail(record.notice)
if (res.success) {
detailData.value = res.data
}
} catch (e: any) {
message.error(e?.message || '获取通知详情失败')
} finally {
detailLoading.value = false
}
}
const closeDetail = () => {
detailVisible.value = false
detailData.value = null
}
const markRead = async (record: UserNoticeListItem) => {
try {
const res = await noticeUserApi.markRead(record.notice)
if (res.success) {
message.success('已标记为已读')
refreshList()
}
} catch (e: any) {
message.error(e?.message || '标记已读失败')
}
}
const markAllRead = async () => {
const ids = displayedNotices.value.filter(n => !n.is_read).map(n => n.notice)
if (ids.length === 0) return
batchLoading.value = true
try {
const res = await noticeUserApi.batchMarkRead(ids)
if (res.success) {
message.success(`已标记 ${res.data?.updated_count || ids.length} 条为已读`)
refreshList()
}
} catch (e: any) {
message.error(e?.message || '批量标记失败')
} finally {
batchLoading.value = false
}
}
const toggleStar = async (record: UserNoticeListItem) => {
try {
const res = await noticeUserApi.toggleStar(record.notice, !record.is_starred)
if (res.success) {
message.success(record.is_starred ? '已取消收藏' : '已收藏')
fetchList()
fetchStatistics()
}
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
onMounted(() => {
fetchList()
fetchStatistics()
})
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.notice-center {
padding: 24px;
background: var(--theme-page-bg, #f5f5f5);
min-height: 100vh;
.page-header {
margin-bottom: 24px;
text-align: center;
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.title-icon {
color: var(--theme-primary, #3b82f6);
}
}
.page-description {
color: var(--theme-text-secondary, #64748b);
font-size: 1.1rem;
margin: 0;
}
}
// 操作栏
.action-bar {
max-width: 1200px;
margin: 0 auto 16px;
display: flex;
justify-content: flex-end;
}
// 统计卡片
.stats-container {
max-width: 1200px;
margin: 0 auto 24px;
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
.stat-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
align-items: center;
gap: 20px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
&.total {
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
}
&.unread {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
&.read {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
&.starred {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
color: var(--theme-text-secondary, #6b7280);
font-size: 14px;
}
}
}
}
}
// 主要内容区域
.main-container {
max-width: 1200px;
margin: 0 auto;
.content-wrapper {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
.tabs-container {
padding: 0 20px;
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
.tech-tabs {
:deep(.ant-tabs-nav) {
margin: 0;
}
}
}
.empty-state {
padding: 80px 24px;
text-align: center;
.empty-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
color: #64748b;
font-size: 32px;
}
.empty-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-text-primary, #374151);
margin: 0 0 12px 0;
}
.empty-description {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
line-height: 1.6;
max-width: 500px;
margin: 0 auto;
}
}
.notices-container {
padding: 24px;
.tech-list {
:deep(.ant-list-item) {
padding: 0;
border: none;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.notice-item {
.notice-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
border: 1px solid var(--theme-card-border, #e5e7eb);
overflow: hidden;
&.unread {
border-left: 4px solid #f59e0b;
background: #fffbeb;
}
&.starred {
border-left: 4px solid #8b5cf6;
background: #faf5ff;
}
.notice-header {
padding: 20px;
display: flex;
gap: 16px;
.notice-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
&.icon-system {
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
}
&.icon-warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
&.icon-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
&.icon-default {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
}
}
.notice-content {
flex: 1;
.title-line {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.notice-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
margin: 0;
flex: 1;
}
.notice-badges {
display: flex;
gap: 8px;
.top-tag {
margin: 0;
}
.status-tag {
margin: 0;
}
}
}
.notice-desc {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
margin: 0 0 12px 0;
line-height: 1.5;
}
.notice-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
.publish-time {
display: flex;
align-items: center;
gap: 6px;
color: #9ca3af;
font-size: 12px;
}
.notice-actions {
display: flex;
gap: 8px;
.action-link {
padding: 4px 8px;
height: auto;
font-size: 12px;
color: #64748b;
transition: all 0.3s ease;
&:hover {
color: var(--theme-primary, #3b82f6);
background: rgba(59, 130, 246, 0.1);
border-radius: 6px;
}
}
}
}
}
}
}
}
}
}
.pagination-container {
padding: 20px;
border-top: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
justify-content: center;
}
}
}
// 详情模态框
.detail-modal {
:deep(.ant-modal-content) {
border-radius: 12px;
}
.detail-content {
.tech-descriptions {
:deep(.ant-descriptions-item-label) {
font-weight: 600;
color: var(--theme-text-primary, #374151);
background: var(--theme-content-bg, #f8fafc);
}
:deep(.ant-descriptions-item-content) {
color: var(--theme-text-primary, #1e293b);
}
.detail-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
}
.detail-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
color: var(--theme-text-primary, #374151);
}
.detail-time {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
}
.status-info {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
}
}
}
</style>

View File

@@ -1,572 +0,0 @@
<template>
<div class="profile-page">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">
<UserOutlined class="title-icon" />
个人资料
</h1>
<p class="page-description">管理您的个人信息和账户设置</p>
</div>
<!-- 用户信息卡片 -->
<div class="user-info-section">
<div class="user-info-card">
<div class="user-avatar-section">
<a-avatar :size="100" class="avatar">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="avatar-info">
<div class="username">{{ userForm.username || '用户' }}</div>
<div class="user-role">普通用户</div>
</div>
</div>
</div>
</div>
<!-- 资料表单区域 -->
<div class="profile-container">
<div class="profile-wrapper">
<div class="panel-header">
<div class="header-left">
<div class="panel-icon">
<SettingOutlined />
</div>
<div class="title-group">
<h3 class="panel-title">基本信息</h3>
<span class="panel-subtitle">更新您的个人资料信息</span>
</div>
</div>
</div>
<div class="panel-content">
<a-form
:model="userForm"
layout="vertical"
@finish="handleSubmit"
class="profile-form"
>
<div class="form-grid">
<div class="form-section">
<h4 class="section-title">
<UserOutlined />
账户信息
</h4>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="用户名" name="username" class="form-item">
<a-input
v-model:value="userForm.username"
disabled
class="form-input"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮箱地址" name="email" class="form-item">
<a-input
v-model:value="userForm.email"
class="form-input"
placeholder="请输入邮箱地址"
>
<template #prefix>
<MailOutlined />
</template>
</a-input>
</a-form-item>
</a-col>
</a-row>
</div>
<div class="form-section">
<h4 class="section-title">
<IdcardOutlined />
个人信息
</h4>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="真实姓名" name="real_name" class="form-item">
<a-input
v-model:value="userForm.real_name"
class="form-input"
placeholder="请输入真实姓名"
>
<template #prefix>
<IdcardOutlined />
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号码" name="phone" class="form-item">
<a-input
v-model:value="userForm.phone"
class="form-input"
placeholder="请输入手机号码"
>
<template #prefix>
<PhoneOutlined />
</template>
</a-input>
</a-form-item>
</a-col>
</a-row>
</div>
<div class="form-section">
<h4 class="section-title">
<CalendarOutlined />
其他信息
</h4>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="性别" name="gender" class="form-item">
<a-select
v-model:value="userForm.gender"
class="form-select"
placeholder="请选择性别"
>
<a-select-option :value="0">未知</a-select-option>
<a-select-option :value="1"></a-select-option>
<a-select-option :value="2"></a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="生日" name="birthday" class="form-item">
<a-date-picker
v-model:value="userForm.birthday"
class="form-date-picker"
placeholder="请选择生日"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</div>
<div class="form-actions">
<a-button
type="primary"
html-type="submit"
:loading="loading"
class="submit-btn"
size="large"
>
<template #icon>
<SaveOutlined />
</template>
保存更改
</a-button>
<a-button
@click="resetForm"
class="reset-btn"
size="large"
>
<template #icon>
<ReloadOutlined />
</template>
重置表单
</a-button>
</div>
</a-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
UserOutlined,
SettingOutlined,
IdcardOutlined,
CalendarOutlined,
SaveOutlined,
ReloadOutlined,
MailOutlined,
PhoneOutlined
} from '@ant-design/icons-vue'
import { userApi, type User } from '@/api/user'
import dayjs from 'dayjs'
const loading = ref(false)
const userForm = ref<Partial<User>>({
username: '',
email: '',
real_name: '',
phone: '',
gender: 0,
birthday: ''
})
const fetchUserInfo = async () => {
try {
const response = await userApi.getUserInfo()
if (response.success) {
userForm.value = {
...response.data,
birthday: response.data.birthday ? dayjs(response.data.birthday) : undefined
}
}
} catch (error) {
console.error('获取用户信息失败:', error)
message.error('获取用户信息失败')
}
}
const handleSubmit = async () => {
loading.value = true
try {
const submitData = {
...userForm.value,
birthday: userForm.value.birthday ? dayjs(userForm.value.birthday).format('YYYY-MM-DD') : undefined
}
const response = await userApi.updateUserInfo(submitData)
if (response.success) {
message.success('个人信息更新成功!')
await fetchUserInfo()
}
} catch (error) {
console.error('更新失败:', error)
message.error('更新失败,请稍后重试')
} finally {
loading.value = false
}
}
const resetForm = async () => {
try {
await fetchUserInfo()
message.success('表单已重置为最新数据')
} catch (error) {
console.error('重置失败:', error)
message.error('重置失败,请刷新页面')
}
}
onMounted(() => {
fetchUserInfo()
})
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.profile-page {
padding: 24px;
background: var(--theme-page-bg, #f5f5f5);
min-height: 100vh;
// 页面头部 - 与知识库中心一致
.page-header {
margin-bottom: 24px;
text-align: center;
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.title-icon {
color: var(--theme-primary, #3b82f6);
}
}
.page-description {
color: var(--theme-text-secondary, #64748b);
font-size: 1.1rem;
margin: 0;
}
}
// 用户信息卡片
.user-info-section {
max-width: 1200px;
margin: 0 auto 24px;
.user-info-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
padding: 32px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
.user-avatar-section {
display: flex;
align-items: center;
gap: 24px;
.avatar {
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border: 3px solid var(--theme-card-border, #e5e7eb);
}
.avatar-info {
.username {
font-size: 24px;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 8px;
}
.user-role {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
font-weight: 500;
}
}
}
}
}
// 资料表单容器
.profile-container {
max-width: 1200px;
margin: 0 auto;
.profile-wrapper {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
flex-direction: column;
height: 100%;
.panel-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
flex-shrink: 0;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.panel-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.title-group {
.panel-title {
color: var(--theme-text-primary, #1e293b);
font-size: 16px;
font-weight: 600;
margin: 0 0 4px 0;
}
.panel-subtitle {
color: var(--theme-text-secondary, #64748b);
font-size: 14px;
}
}
}
}
.panel-content {
flex: 1;
padding: 24px;
.profile-form {
.form-grid {
display: flex;
flex-direction: column;
gap: 32px;
.form-section {
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
margin: 0 0 20px 0;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
}
.form-item {
margin-bottom: 20px;
:deep(.ant-form-item-label) {
label {
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
font-size: 14px;
}
}
.form-input {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:focus,
&:hover {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&:disabled {
background: var(--theme-card-hover-bg, #f9fafb);
color: var(--theme-text-secondary, #6b7280);
border-color: var(--theme-card-border, #e5e7eb);
}
}
.form-select {
:deep(.ant-select-selector) {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
}
}
:deep(.ant-select-focused .ant-select-selector) {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.form-date-picker {
:deep(.ant-picker) {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
}
&.ant-picker-focused {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
}
}
}
}
.form-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--theme-card-border, #e5e7eb);
.submit-btn {
border-radius: 8px;
height: 44px;
padding: 0 32px;
font-weight: 600;
font-size: 15px;
transition: all 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
}
.reset-btn {
border-radius: 8px;
height: 44px;
padding: 0 32px;
font-weight: 500;
font-size: 15px;
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
color: var(--theme-primary, #3b82f6);
}
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.profile-page {
padding: 16px;
.user-info-section {
.user-info-card {
padding: 24px;
.user-avatar-section {
flex-direction: column;
text-align: center;
gap: 16px;
}
}
}
.profile-container {
.profile-wrapper {
.panel-content {
padding: 20px;
.profile-form {
.form-grid {
gap: 24px;
.form-section {
.form-item {
margin-bottom: 16px;
}
}
}
.form-actions {
flex-direction: column;
align-items: stretch;
.submit-btn,
.reset-btn {
width: 100%;
}
}
}
}
}
}
}
}
</style>

View File

@@ -1,835 +0,0 @@
<template>
<div class="system-monitor">
<div class="page-header">
<h1 class="page-title">
<DatabaseOutlined class="title-icon" />
系统监控
</h1>
<p class="page-description">实时监控系统运行状态和性能指标</p>
</div>
<div class="monitor-content">
<a-spin :spinning="loading">
<!-- 刷新按钮 -->
<div class="refresh-actions">
<a-button
:loading="loading"
@click="fetchAll"
type="primary"
size="large"
>
<template #icon>
<ReloadOutlined />
</template>
刷新数据
</a-button>
</div>
<!-- 系统概览卡片 -->
<div class="overview-cards" :class="currentLayoutConfig.overviewClass">
<a-row :gutter="currentLayoutConfig.overviewGutter">
<!-- 系统信息 -->
<a-col
:xs="currentLayoutConfig.overviewLayout.xs"
:sm="currentLayoutConfig.overviewLayout.sm"
:md="currentLayoutConfig.overviewLayout.md"
:lg="currentLayoutConfig.overviewLayout.lg"
:xl="currentLayoutConfig.overviewLayout.xl"
>
<div class="monitor-card system-card">
<div class="card-header">
<div class="card-icon">
<DesktopOutlined />
</div>
<h3 class="card-title">系统信息</h3>
</div>
<div class="card-content">
<div class="info-grid">
<div class="info-item">
<span class="info-label">主机名</span>
<span class="info-value">{{ system?.hostname || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">平台</span>
<span class="info-value">{{ system?.platform || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">架构</span>
<span class="info-value">{{ system?.architecture || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">启动时间</span>
<span class="info-value">{{ system?.boot_time || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">运行时长</span>
<span class="info-value">{{ system?.uptime || '-' }}</span>
</div>
</div>
</div>
</div>
</a-col>
<!-- CPU信息 -->
<a-col
:xs="currentLayoutConfig.overviewLayout.xs"
:sm="currentLayoutConfig.overviewLayout.sm"
:md="currentLayoutConfig.overviewLayout.md"
:lg="currentLayoutConfig.overviewLayout.lg"
:xl="currentLayoutConfig.overviewLayout.xl"
>
<div class="monitor-card cpu-card">
<div class="card-header">
<div class="card-icon">
<DesktopOutlined />
</div>
<h3 class="card-title">CPU 信息</h3>
</div>
<div class="card-content">
<div class="cpu-stats">
<div class="stat-item">
<div class="stat-value">{{ toNum(cpu?.cpu_count) ?? '-' }}</div>
<div class="stat-label">核心数</div>
</div>
<div class="stat-item">
<div class="stat-value cpu-usage">{{ toNum(cpu?.cpu_percent) ?? '-' }}%</div>
<div class="stat-label">使用率</div>
</div>
</div>
<div class="cpu-details">
<div class="detail-item">
<span class="detail-label">当前频率</span>
<span class="detail-value">{{ toNum(cpu?.cpu_freq?.current) ?? '-' }} MHz</span>
</div>
<div class="detail-item">
<span class="detail-label">频率范围</span>
<span class="detail-value">{{ toNum(cpu?.cpu_freq?.min) ?? '-' }} - {{ toNum(cpu?.cpu_freq?.max) ?? '-' }} MHz</span>
</div>
<div class="detail-item">
<span class="detail-label">负载均值</span>
<span class="detail-value">{{ Array.isArray(cpu?.load_avg) ? cpu?.load_avg?.join(', ') : '-' }}</span>
</div>
</div>
</div>
</div>
</a-col>
<!-- 内存信息 -->
<a-col
:xs="currentLayoutConfig.overviewLayout.xs"
:sm="currentLayoutConfig.overviewLayout.sm"
:md="currentLayoutConfig.overviewLayout.md"
:lg="currentLayoutConfig.overviewLayout.lg"
:xl="currentLayoutConfig.overviewLayout.xl"
>
<div class="monitor-card memory-card">
<div class="card-header">
<div class="card-icon">
<DatabaseOutlined />
</div>
<h3 class="card-title">内存使用</h3>
</div>
<div class="card-content">
<div class="memory-progress">
<a-progress
:percent="toNum(memory?.percent) ?? 0"
:stroke-color="getMemoryColor(toNum(memory?.percent) ?? 0)"
:show-info="false"
stroke-width="8"
/>
<div class="progress-text">{{ toNum(memory?.percent) ?? 0 }}%</div>
</div>
<div class="memory-details">
<div class="memory-item">
<span class="memory-label">总量</span>
<span class="memory-value">{{ formatBytesMaybe(memory?.total) }}</span>
</div>
<div class="memory-item">
<span class="memory-label">已用</span>
<span class="memory-value">{{ formatBytesMaybe(memory?.used) }}</span>
</div>
<div class="memory-item">
<span class="memory-label">可用</span>
<span class="memory-value">{{ formatBytesMaybe(memory?.available) }}</span>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- 详细监控数据 -->
<div class="detail-sections" :class="currentLayoutConfig.detailClass">
<a-row :gutter="currentLayoutConfig.detailGutter">
<!-- GPU信息 -->
<a-col
:xs="currentLayoutConfig.detailLayout.xs"
:sm="currentLayoutConfig.detailLayout.sm"
:md="currentLayoutConfig.detailLayout.md"
:lg="currentLayoutConfig.detailLayout.lg"
:xl="currentLayoutConfig.detailLayout.xl"
>
<div class="monitor-card gpu-card">
<div class="card-header">
<div class="card-icon">
<DatabaseOutlined />
</div>
<h3 class="card-title">GPU 信息</h3>
</div>
<div class="card-content">
<div v-if="gpu?.gpu_available" class="gpu-table">
<a-table
:data-source="gpu?.gpu_info || []"
:columns="gpuColumns"
row-key="id"
size="small"
:pagination="false"
/>
</div>
<div v-else class="gpu-unavailable">
<a-alert
:message="gpu?.message || '未检测到GPU设备'"
type="info"
show-icon
/>
</div>
<div class="gpu-timestamp">更新时间{{ gpu?.timestamp }}</div>
</div>
</div>
</a-col>
<!-- 磁盘信息 -->
<a-col
:xs="currentLayoutConfig.detailLayout.xs"
:sm="currentLayoutConfig.detailLayout.sm"
:md="currentLayoutConfig.detailLayout.md"
:lg="currentLayoutConfig.detailLayout.lg"
:xl="currentLayoutConfig.detailLayout.xl"
>
<div class="monitor-card disk-card">
<div class="card-header">
<div class="card-icon">
<DatabaseOutlined />
</div>
<h3 class="card-title">磁盘使用</h3>
</div>
<div class="card-content">
<a-table
:data-source="disks"
:columns="diskColumns"
row-key="device"
size="small"
:pagination="false"
/>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="currentLayoutConfig.detailGutter" style="margin-top: 24px;">
<!-- 网络信息 -->
<a-col
:xs="currentLayoutConfig.detailLayout.xs"
:sm="currentLayoutConfig.detailLayout.sm"
:md="currentLayoutConfig.detailLayout.md"
:lg="currentLayoutConfig.detailLayout.lg"
:xl="currentLayoutConfig.detailLayout.xl"
>
<div class="monitor-card network-card">
<div class="card-header">
<div class="card-icon">
<WifiOutlined />
</div>
<h3 class="card-title">网络接口</h3>
</div>
<div class="card-content">
<a-table
:data-source="network"
:columns="networkColumns"
row-key="interface"
size="small"
:pagination="false"
/>
</div>
</div>
</a-col>
<!-- 进程信息 -->
<a-col
:xs="currentLayoutConfig.detailLayout.xs"
:sm="currentLayoutConfig.detailLayout.sm"
:md="currentLayoutConfig.detailLayout.md"
:lg="currentLayoutConfig.detailLayout.lg"
:xl="currentLayoutConfig.detailLayout.xl"
>
<div class="monitor-card process-card">
<div class="card-header">
<div class="card-icon">
<CodeOutlined />
</div>
<h3 class="card-title">进程监控 (Top 10)</h3>
</div>
<div class="card-content">
<a-table
:data-source="processes"
:columns="processColumns"
row-key="pid"
size="small"
:pagination="false"
/>
</div>
</div>
</a-col>
</a-row>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue'
import {
ReloadOutlined,
DesktopOutlined,
DatabaseOutlined,
WifiOutlined,
CodeOutlined
} from '@ant-design/icons-vue'
import { systemMonitorApi, type SystemInfo, type CpuInfo, type MemoryInfo, type DiskInfo, type NetworkInfo, type ProcessInfo, type GpuInfoResponse } from '@/api/system_monitor'
import { message } from 'ant-design-vue'
const loading = ref(false)
// 系统监控布局配置
const systemMonitorLayouts = [
{
name: 'default',
label: '标准布局',
description: '概览卡片3列详细信息2列标准展示',
overviewClass: 'overview-3col',
detailClass: 'detail-2col',
overviewLayout: { xs: 24, md: 12, lg: 8 },
detailLayout: { xs: 24, lg: 12 },
overviewGutter: [16, 16],
detailGutter: [16, 16]
},
{
name: 'compact',
label: '紧凑布局',
description: '概览卡片2列详细信息全宽适合小屏',
overviewClass: 'overview-2col',
detailClass: 'detail-full',
overviewLayout: { xs: 24, sm: 12, lg: 12 },
detailLayout: { xs: 24, lg: 24 },
overviewGutter: [12, 12],
detailGutter: [12, 12]
},
{
name: 'wide',
label: '宽屏布局',
description: '概览卡片4列详细信息并排充分利用宽屏',
overviewClass: 'overview-4col',
detailClass: 'detail-side',
overviewLayout: { xs: 24, sm: 12, lg: 6, xl: 6 },
detailLayout: { xs: 24, lg: 12 },
overviewGutter: [20, 20],
detailGutter: [20, 20]
}
]
// 当前布局
const currentLayout = ref<string>('default')
// 计算当前布局配置
const currentLayoutConfig = computed(() => {
return systemMonitorLayouts.find(l => l.name === currentLayout.value) || systemMonitorLayouts[0]
})
// 加载布局配置
const loadLayout = () => {
const savedLayout = localStorage.getItem('systemMonitorLayout')
if (savedLayout && systemMonitorLayouts.find(l => l.name === savedLayout)) {
currentLayout.value = savedLayout
} else {
currentLayout.value = 'default'
}
}
// 监听布局变化事件
const handleLayoutChange = () => {
loadLayout()
}
onMounted(() => {
loadLayout()
window.addEventListener('systemMonitorLayoutChanged', handleLayoutChange)
fetchAll()
})
onUnmounted(() => {
window.removeEventListener('systemMonitorLayoutChanged', handleLayoutChange)
})
const system = ref<SystemInfo | null>(null)
const cpu = ref<CpuInfo | null>(null)
const memory = ref<MemoryInfo | null>(null)
const disks = ref<DiskInfo[]>([])
const network = ref<NetworkInfo[]>([])
const processes = ref<ProcessInfo[]>([])
const gpu = ref<GpuInfoResponse | null>(null)
const fetchAll = async () => {
loading.value = true
try {
const res = await systemMonitorApi.getMonitor()
if (res.success) {
system.value = res.data?.system ?? null
cpu.value = res.data?.cpu ?? null
memory.value = res.data?.memory ?? null
disks.value = res.data?.disks ?? []
network.value = res.data?.network ?? []
processes.value = res.data?.processes ?? []
// 若综合接口缺少关键数据,回退单项接口
const fallbacks: Promise<any>[] = []
if (!cpu.value || typeof cpu.value.cpu_percent !== 'number') {
fallbacks.push(systemMonitorApi.getCpu().then(r => { if (r.success) cpu.value = r.data }))
}
if (!memory.value || typeof memory.value.percent !== 'number') {
fallbacks.push(systemMonitorApi.getMemory().then(r => { if (r.success) memory.value = r.data }))
}
if (!disks.value || disks.value.length === 0) {
fallbacks.push(systemMonitorApi.getDisks().then(r => { if (r.success) disks.value = r.data }))
}
if (!network.value || network.value.length === 0) {
fallbacks.push(systemMonitorApi.getNetwork().then(r => { if (r.success) network.value = r.data }))
}
if (!processes.value || processes.value.length === 0) {
fallbacks.push(systemMonitorApi.getProcesses().then(r => { if (r.success) processes.value = r.data }))
}
// GPU始终尝试获取详细信息
const gpuRes = await systemMonitorApi.getGpu()
if (gpuRes.success) {
gpu.value = gpuRes.data
} else {
const g = Array.isArray(res.data?.gpus) && res.data.gpus.length > 0 ? res.data.gpus[0] : undefined
if (g) {
gpu.value = { gpu_available: g.gpu_available, message: g.message, timestamp: g.timestamp, gpu_info: undefined }
}
}
if (fallbacks.length) await Promise.allSettled(fallbacks)
} else {
message.error(res.message || '获取监控数据失败')
}
} catch (err: any) {
message.error(err?.message || '网络错误')
} finally {
loading.value = false
}
}
// 表格列定义
const diskColumns = [
{ title: '设备', dataIndex: 'device', key: 'device' },
{ title: '挂载点', dataIndex: 'mountpoint', key: 'mountpoint' },
{ title: '类型', dataIndex: 'fstype', key: 'fstype' },
{ title: '总容量', dataIndex: 'total', key: 'total', customRender: ({ text }: any) => formatBytes(text) },
{ title: '已用', dataIndex: 'used', key: 'used', customRender: ({ text }: any) => formatBytes(text) },
{ title: '空闲', dataIndex: 'free', key: 'free', customRender: ({ text }: any) => formatBytes(text) },
{ title: '使用率', dataIndex: 'percent', key: 'percent', customRender: ({ text }: any) => `${text}%` },
]
const networkColumns = [
{ title: '接口', dataIndex: 'interface', key: 'interface' },
{ title: '发送', dataIndex: 'bytes_sent', key: 'bytes_sent', customRender: ({ text }: any) => formatBytes(text) },
{ title: '接收', dataIndex: 'bytes_recv', key: 'bytes_recv', customRender: ({ text }: any) => formatBytes(text) },
{ title: '发送包', dataIndex: 'packets_sent', key: 'packets_sent' },
{ title: '接收包', dataIndex: 'packets_recv', key: 'packets_recv' },
]
const processColumns = [
{ title: 'PID', dataIndex: 'pid', key: 'pid' },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: 'CPU%', dataIndex: 'cpu_percent', key: 'cpu_percent' },
{ title: '内存%', dataIndex: 'memory_percent', key: 'memory_percent' },
{ title: 'RSS', dataIndex: ['memory_info','rss'], key: 'rss', customRender: ({ text }: any) => formatBytes(text) },
{ title: 'VMS', dataIndex: ['memory_info','vms'], key: 'vms', customRender: ({ text }: any) => formatBytes(text) },
{ title: '创建时间', dataIndex: 'create_time', key: 'create_time' },
]
const gpuColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '负载%', dataIndex: 'load', key: 'load' },
{ title: '总显存(MB)', dataIndex: 'memory_total', key: 'memory_total' },
{ title: '已用(MB)', dataIndex: 'memory_used', key: 'memory_used' },
{ title: '显存利用率%', dataIndex: 'memory_util', key: 'memory_util' },
{ title: '温度(℃)', dataIndex: 'temperature', key: 'temperature' },
]
function formatBytes(val?: number) {
if (!val && val !== 0) return '-'
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(val) / Math.log(1024))
const v = (val / Math.pow(1024, i))
return `${v.toFixed(2)} ${sizes[i]}`
}
function toNum(v: any): number | undefined {
if (typeof v === 'number') return v
if (typeof v === 'string') {
const n = parseFloat(v)
return Number.isFinite(n) ? n : undefined
}
return undefined
}
function formatBytesMaybe(v: any): string {
const n = toNum(v)
if (n === undefined) return '-'
return formatBytes(n)
}
function getMemoryColor(percent: number): string {
if (percent < 50) return '#52c41a'
if (percent < 80) return '#faad14'
return '#ff4d4f'
}
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.system-monitor {
padding: 24px;
background: var(--theme-page-bg, #f5f5f5);
min-height: 100vh;
.page-header {
margin-bottom: 24px;
text-align: center;
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.title-icon {
color: var(--theme-primary, #3b82f6);
}
}
.page-description {
color: var(--theme-text-secondary, #64748b);
font-size: 1.1rem;
margin: 0;
}
}
// 监控内容
.monitor-content {
// 刷新按钮
.refresh-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 24px;
}
// 概览卡片
.overview-cards {
margin-bottom: 24px;
// 布局类
&.overview-3col {
// 3列布局默认
}
&.overview-2col {
// 2列布局
}
&.overview-4col {
// 4列布局
}
.monitor-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
flex-direction: column;
height: 100%;
min-height: 300px;
.card-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
flex-shrink: 0;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
.card-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
margin: 0;
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
// 系统信息样式
.info-grid {
display: flex;
flex-direction: column;
gap: 16px;
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--theme-content-bg, #f8fafc);
border-radius: 8px;
border-left: 4px solid var(--theme-primary, #3b82f6);
.info-label {
font-size: 14px;
color: var(--theme-text-secondary, #64748b);
font-weight: 500;
}
.info-value {
font-size: 14px;
color: var(--theme-text-primary, #1e293b);
font-weight: 600;
}
}
}
// CPU统计样式
.cpu-stats {
display: flex;
gap: 24px;
margin-bottom: 20px;
.stat-item {
text-align: center;
flex: 1;
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
margin-bottom: 4px;
&.cpu-usage {
color: var(--theme-primary, #3b82f6);
}
}
.stat-label {
font-size: 14px;
color: var(--theme-text-secondary, #64748b);
font-weight: 500;
}
}
}
.cpu-details {
display: flex;
flex-direction: column;
gap: 12px;
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--theme-content-bg, #f8fafc);
border-radius: 6px;
.detail-label {
font-size: 13px;
color: var(--theme-text-secondary, #64748b);
}
.detail-value {
font-size: 13px;
color: var(--theme-text-primary, #1e293b);
font-weight: 600;
}
}
}
// 内存进度样式
.memory-progress {
position: relative;
margin-bottom: 20px;
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
font-weight: 700;
color: var(--theme-text-primary, #1e293b);
}
}
.memory-details {
display: flex;
flex-direction: column;
gap: 12px;
.memory-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--theme-content-bg, #f8fafc);
border-radius: 6px;
.memory-label {
font-size: 13px;
color: var(--theme-text-secondary, #64748b);
}
.memory-value {
font-size: 13px;
color: var(--theme-text-primary, #1e293b);
font-weight: 600;
}
}
}
// GPU不可用样式
.gpu-unavailable {
margin-bottom: 16px;
}
.gpu-timestamp {
font-size: 12px;
color: var(--theme-text-secondary, #64748b);
text-align: center;
margin-top: 12px;
}
}
}
}
// 详细监控区域
.detail-sections {
// 布局类
&.detail-2col {
// 2列布局默认
}
&.detail-full {
// 全宽布局
}
&.detail-side {
// 并排布局
}
.monitor-card {
background: var(--theme-card-bg, white);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
flex-direction: column;
min-height: 400px;
height: 100%;
.card-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
flex-shrink: 0;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
.card-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
margin: 0;
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
// 表格样式优化
:deep(.ant-table) {
.ant-table-thead > tr > th {
background: var(--theme-content-bg, #f8fafc);
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
}
.ant-table-tbody > tr > td {
border-bottom: 1px solid var(--theme-card-border, #f1f5f9);
}
.ant-table-tbody > tr:hover > td {
background: var(--theme-content-bg, #f8fafc);
}
}
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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