添加数据库文件
This commit is contained in:
@@ -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>
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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/')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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/')
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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/`)
|
||||
},
|
||||
}
|
||||
@@ -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 } })
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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/')
|
||||
}
|
||||
}
|
||||
@@ -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/'),
|
||||
}
|
||||
@@ -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/')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: '系统配置错误,请联系管理员',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
13
hertz_server_diango_ui/src/types/env.d.ts
vendored
13
hertz_server_diango_ui/src/types/env.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:优先 GPU(WebGPU/WebGL),再回退 WASM
|
||||
// 支持通过 localStorage 开关强制使用 WASM:localStorage.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) WebGL(GPU 加速,兼容更好)
|
||||
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
|
||||
try {
|
||||
await createWithEP('webgl')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGL 推理')
|
||||
} catch (e2) {
|
||||
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) WASM(CPU)
|
||||
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()
|
||||
@@ -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>© 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
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
@@ -1 +0,0 @@
|
||||
<template>这是我user_pages</template>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
1
hertz_server_diango_ui/src/vite-env.d.ts
vendored
1
hertz_server_diango_ui/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user