This commit is contained in:
2025-12-09 14:46:02 +08:00
parent c7a22a288a
commit abe314fdc8
76 changed files with 7601 additions and 1667 deletions

View File

@@ -1,5 +1,5 @@
# API 基础地址
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_API_BASE_URL=http://192.168.124.23:8000
# 应用配置
VITE_APP_TITLE=Hertz Admin

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_API_BASE_URL=http://localhost:8000
VITE_TEMPLATE_SETUP_MODE=true

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_API_BASE_URL=http://192.168.124.40:8002
VITE_TEMPLATE_SETUP_MODE=true

View File

@@ -17,7 +17,7 @@
- **工程化完善**TS 强类型、模块化 API、统一请求封装、权限化菜单/路由
- **设计统一**:全局“超现代风格”主题,卡片 / 弹窗 / 按钮 / 输入 / 分页风格一致
- **业务可复用**
- 知识库管理:分类树 + 列表搜索 + 编辑/发布
- 文章管理:分类树 + 列表搜索 + 编辑/发布
- YOLO 模型:模型管理、模型类别管理、告警处理中心、检测历史管理
- AI 助手:多会话列表 + 消息记录 + 多布局对话界面(含错误调试信息)
- 认证体系:登录/注册、验证码
@@ -246,6 +246,72 @@ npm run dev
- 修改 `src/styles/variables.scss` 中的主色、背景色、圆角、阴影
- 如需大改导航栏、卡片风格,优先在全局样式里做统一,而不是每页重新写
## 🧩 模块选择与模板模式
- **模块配置文件**
- 路径:`src/config/hertz_modules.ts`
- 内容:
- 使用 `HERTZ_MODULES` 统一管理“管理端 / 用户端”各功能模块
- 每个模块包含:`key`(模块标识)、`label`(展示名称)、`group`admin/user、`defaultEnabled`(是否默认启用)
- 运行时通过 `isModuleEnabled` / `getEnabledModuleKeys` 控制路由和菜单是否展示对应模块。
- **模块选择页面(功能 DIY**
- 页面:`src/views/ModuleSetup.vue`
- 路由:`/template/modules`
- 说明:
1. 勾选需要启用的模块,未勾选的模块在菜单和路由中隐藏(仅运行时屏蔽,不改动源码)。
2. 点击“保存配置并刷新”可多次预览效果;点击“保存并跳转登录”会在保存后跳转到登录页。
3. 选择结果会以 `hertz_enabled_modules` 的形式保存在浏览器 Local Storage 中。
- **模板模式开关**
- 通过环境变量控制:`VITE_TEMPLATE_SETUP_MODE`
- 建议在开发环境 (`.env.development`) 中开启:
```bash
VITE_TEMPLATE_SETUP_MODE=true
```
- 当模板模式开启且浏览器中 **没有** `hertz_enabled_modules` 记录时,路由守卫会在首次进入时自动重定向到 `/template/modules`,强制先完成模块选择。
- 如果已经配置过模块,下次 `npm run dev` 将直接进入系统。如需重新进入模块选择页:
1. 打开浏览器开发者工具 → Application → Local Storage
2. 选择当前站点,删除键 `hertz_enabled_modules`
3. 刷新页面即可再次进入模块选择流程。
## ✂️ 一键裁剪npm run prune
> 适用于已经确定“哪些功能模块不再需要”的场景,用于真正瘦身前端代码体积。建议在执行前先提交一次 Git。
- **脚本位置与命令**
- 脚本:`scripts/prune-modules.mjs`
- 命令:
```bash
npm run prune
```
- **推荐使用流程**
1. 启动开发环境:`npm run dev`。
2. 打开 `/template/modules`,通过勾选确认“需要保留的模块”,用“保存配置并刷新”反复调试菜单/路由效果。
3. 确认无误后,关闭开发服务器。
4. 在终端执行 `npm run prune`,按照 CLI 提示:
- 选择要“裁剪掉”的模块(通常是你在模块选择页面中未勾选的模块)。
- 选择裁剪模式:
- **模式 1仅屏蔽**
- 修改 `admin_menu.ts` / `user_menu_ai.ts` 中对应模块的 `moduleKey`,加上 `__pruned__` 前缀
- 注释组件映射行,使这些模块在菜单和路由中完全隐藏
- **不删除任何 `.vue` 文件**,方便后续恢复
- **模式 2删除**
- 在模式 1 的基础上,额外删除对应模块的视图文件,如 `src/views/admin_page/UserManagement.vue` 等
- 这是不可逆操作,建议先在模式 1 下验证,再使用模式 2 做最终瘦身
- **影响范围(前端)**
- 管理端:
- `src/router/admin_menu.ts` 中对应模块的菜单配置和组件映射
- `src/views/admin_page/*.vue` 中不需要的页面(仅在删除模式下移除)
- 用户端:
- `src/router/user_menu_ai.ts` 中对应模块配置
- `src/views/user_pages/*.vue` 中不需要的页面(仅在删除模式下移除)
## 📜 NPM 脚本
```json
@@ -253,7 +319,8 @@ npm run dev
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"prune": "node scripts/prune-modules.mjs"
}
}
```

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']

View File

@@ -25,12 +25,10 @@
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"daisyui": "^5.1.13",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.4.0",
"postcss": "^8.5.6",
"sass-embedded": "^1.93.0",
"tailwindcss": "^4.1.13",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"unplugin-vue-components": "^29.1.0",
@@ -2663,16 +2661,6 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/daisyui": {
"version": "5.1.13",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.1.13.tgz",
"integrity": "sha512-KWPF/4R+EHTJRqKZFNmSDPfAZ5xeS6YWB/2kS7Y6wGKg+atscUi2DOp6HoDD/OgGML0PJTtTpgwpTfeHVfjk7w==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
@@ -5141,13 +5129,6 @@
"node": ">=16.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"prune": "node scripts/prune-modules.mjs"
},
"dependencies": {
"@types/node": "^24.5.2",

View File

@@ -0,0 +1,392 @@
#!/usr/bin/env node
// 一键裁剪脚本:根据功能模块删除或屏蔽对应的菜单配置和页面文件
// 设计原则:
// - 先通过运行时模块开关/页面确认要保留哪些模块
// - 然后运行本脚本,选择要“裁剪掉”的模块,以及裁剪模式:
// 1) 仅屏蔽(修改 moduleKey使其永远不会被启用保留页面文件
// 2) 删除(在 1 的基础上,再删除对应 .vue 页面文件)
// - 脚本只操作前端代码,不影响后端
import fs from 'fs'
import path from 'path'
import readline from 'readline'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const projectRoot = path.resolve(__dirname, '..')
/** 模块定义(与 src/config/hertz_modules.ts 保持一致) */
const MODULES = [
{ key: 'admin.user-management', label: '管理端 · 用户管理', group: 'admin' },
{ key: 'admin.department-management', label: '管理端 · 部门管理', group: 'admin' },
{ key: 'admin.menu-management', label: '管理端 · 菜单管理', group: 'admin' },
{ key: 'admin.role-management', label: '管理端 · 角色管理', group: 'admin' },
{ key: 'admin.notification-management', label: '管理端 · 通知管理', group: 'admin' },
{ key: 'admin.log-management', label: '管理端 · 日志管理', group: 'admin' },
{ key: 'admin.knowledge-base', label: '管理端 · 文章管理', group: 'admin' },
{ key: 'admin.yolo-model', label: '管理端 · YOLO 模型相关', group: 'admin' },
{ key: 'user.system-monitor', label: '用户端 · 系统监控', group: 'user' },
{ key: 'user.ai-chat', label: '用户端 · AI 助手', group: 'user' },
{ key: 'user.yolo-detection', label: '用户端 · YOLO 检测', group: 'user' },
{ key: 'user.live-detection', label: '用户端 · 实时检测', group: 'user' },
{ key: 'user.detection-history', label: '用户端 · 检测历史', group: 'user' },
{ key: 'user.alert-center', label: '用户端 · 告警中心', group: 'user' },
{ key: 'user.notice-center', label: '用户端 · 通知中心', group: 'user' },
{ key: 'user.knowledge-center', label: '用户端 · 知识库中心', group: 'user' },
]
/**
* 每个模块对应的裁剪配置:
* - adminModuleKey / userModuleKey: 在路由配置文件中的 moduleKey 值
* - adminComponentNames / userComponentNames: 在组件映射对象中的组件名(*.vue
* - viewFiles: 可以安全删除的页面文件(相对项目根路径)
*/
const PRUNE_CONFIG = {
'admin.user-management': {
adminModuleKey: 'admin.user-management',
adminComponentNames: ['UserManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/UserManagement.vue'],
},
'admin.department-management': {
adminModuleKey: 'admin.department-management',
adminComponentNames: ['DepartmentManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/DepartmentManagement.vue'],
},
'admin.menu-management': {
adminModuleKey: 'admin.menu-management',
adminComponentNames: ['MenuManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/MenuManagement.vue'],
},
'admin.role-management': {
adminModuleKey: 'admin.role-management',
adminComponentNames: ['Role.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/Role.vue'],
},
'admin.notification-management': {
adminModuleKey: 'admin.notification-management',
adminComponentNames: ['NotificationManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/NotificationManagement.vue'],
},
'admin.log-management': {
adminModuleKey: 'admin.log-management',
adminComponentNames: ['LogManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/LogManagement.vue'],
},
'admin.knowledge-base': {
adminModuleKey: 'admin.knowledge-base',
adminComponentNames: ['KnowledgeBaseManagement.vue'],
userModuleKey: null,
userComponentNames: [],
viewFiles: ['src/views/admin_page/KnowledgeBaseManagement.vue'],
},
'admin.yolo-model': {
adminModuleKey: 'admin.yolo-model',
adminComponentNames: [
'ModelManagement.vue',
'AlertLevelManagement.vue',
'AlertProcessingCenter.vue',
'DetectionHistoryManagement.vue',
],
userModuleKey: null,
userComponentNames: [],
viewFiles: [
'src/views/admin_page/ModelManagement.vue',
'src/views/admin_page/AlertLevelManagement.vue',
'src/views/admin_page/AlertProcessingCenter.vue',
'src/views/admin_page/DetectionHistoryManagement.vue',
],
},
'user.system-monitor': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.system-monitor',
userComponentNames: ['SystemMonitor.vue'],
viewFiles: ['src/views/user_pages/SystemMonitor.vue'],
},
'user.ai-chat': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.ai-chat',
userComponentNames: ['AiChat.vue'],
viewFiles: ['src/views/user_pages/AiChat.vue'],
},
'user.yolo-detection': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.yolo-detection',
userComponentNames: ['YoloDetection.vue'],
viewFiles: ['src/views/user_pages/YoloDetection.vue'],
},
'user.live-detection': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.live-detection',
userComponentNames: ['LiveDetection.vue'],
viewFiles: ['src/views/user_pages/LiveDetection.vue'],
},
'user.detection-history': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.detection-history',
userComponentNames: ['DetectionHistory.vue'],
viewFiles: ['src/views/user_pages/DetectionHistory.vue'],
},
'user.alert-center': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.alert-center',
userComponentNames: ['AlertCenter.vue'],
viewFiles: ['src/views/user_pages/AlertCenter.vue'],
},
'user.notice-center': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.notice-center',
userComponentNames: ['NoticeCenter.vue'],
viewFiles: ['src/views/user_pages/NoticeCenter.vue'],
},
'user.knowledge-center': {
adminModuleKey: null,
adminComponentNames: [],
userModuleKey: 'user.knowledge-center',
userComponentNames: ['KnowledgeCenter.vue'],
// 注意:这里只删除 KnowledgeCenter.vue保留 KnowledgeDetail.vue避免复杂路由修改
viewFiles: ['src/views/user_pages/KnowledgeCenter.vue'],
},
}
const ADMIN_MENU_FILE = 'src/router/admin_menu.ts'
const USER_MENU_FILE = 'src/router/user_menu_ai.ts'
/**
* 简单的 CLI 交互封装
*/
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
function ask(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim())
})
})
}
function resolvePath(relativePath) {
return path.resolve(projectRoot, relativePath)
}
function readTextFile(relativePath) {
const full = resolvePath(relativePath)
if (!fs.existsSync(full)) {
return null
}
return fs.readFileSync(full, 'utf8')
}
function writeTextFile(relativePath, content) {
const full = resolvePath(relativePath)
fs.writeFileSync(full, content, 'utf8')
}
function commentComponentLines(content, componentNames) {
if (!componentNames || componentNames.length === 0) return content
const lines = content.split('\n')
const nameSet = new Set(componentNames)
const updated = lines.map((line) => {
const trimmed = line.trim()
if (trimmed.startsWith('//')) return line
for (const name of nameSet) {
if (line.includes(`'${name}'`)) {
return `// ${line}`
}
}
return line
})
return updated.join('\n')
}
function updateModuleKey(content, originalKey) {
if (!originalKey) return content
const patterns = [
`moduleKey: '${originalKey}'`,
`moduleKey: "${originalKey}"`,
]
const pruned = `moduleKey: '__pruned__${originalKey}'`
if (content.includes(pruned)) {
return content
}
let updated = content
let found = false
for (const p of patterns) {
if (updated.includes(p)) {
updated = updated.replace(p, pruned)
found = true
}
}
if (!found) {
console.warn(`⚠️ 未在文件中找到 moduleKey: ${originalKey}`)
}
return updated
}
function collectViewFilesForModules(selectedKeys) {
const files = new Set()
for (const key of selectedKeys) {
const cfg = PRUNE_CONFIG[key]
if (!cfg) continue
for (const f of cfg.viewFiles) {
files.add(f)
}
}
return Array.from(files)
}
async function main() {
console.log('===== Hertz 模板 · 一键裁剪脚本 =====')
console.log('说明:')
console.log('1. 建议先在浏览器里通过“模板模式 + 模块选择页”确认要保留的模块')
console.log('2. 然后关闭 dev 服务器,运行本脚本选择要裁剪掉的模块')
console.log('3. 先可选择“仅屏蔽”,确认无误后,再选择“删除”彻底缩减代码体积')
console.log('')
console.log('当前可裁剪模块:')
MODULES.forEach((m, index) => {
console.log(`${index + 1}. [${m.group}] ${m.label} (${m.key})`)
})
console.log('')
const indexAnswer = await ask('请输入要“裁剪掉”的模块序号(多个用逗号分隔,例如 2,4,7或直接回车取消')
if (!indexAnswer) {
console.log('未选择任何模块,退出。')
rl.close()
return
}
const indexes = indexAnswer
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n >= 1 && n <= MODULES.length)
if (indexes.length === 0) {
console.log('未解析出有效的序号,退出。')
rl.close()
return
}
const selectedModules = Array.from(new Set(indexes.map((i) => MODULES[i - 1])))
console.log('\n将要裁剪的模块')
selectedModules.forEach((m) => {
console.log(`- [${m.group}] ${m.label} (${m.key})`)
})
console.log('\n裁剪模式')
console.log('1) 仅屏蔽模块:')
console.log(' - 修改 router 配置中的 moduleKey 为 __pruned__...')
console.log(' - 生成的菜单和路由中将完全隐藏这些模块')
console.log(' - 不删除任何 .vue 页面文件(可随时恢复)')
console.log('2) 删除模块:')
console.log(' - 在 1 的基础上,额外删除对应的 .vue 页面文件')
console.log(' - 删除操作不可逆,请确保已经提交或备份代码\n')
const modeAnswer = await ask('请选择裁剪模式1 = 仅屏蔽2 = 删除):')
const mode = modeAnswer === '2' ? 'delete' : 'comment'
const viewFiles = collectViewFilesForModules(selectedModules.map((m) => m.key))
console.log('\n即将进行如下修改')
console.log('- 修改文件: src/router/admin_menu.ts按需')
console.log('- 修改文件: src/router/user_menu_ai.ts按需')
if (mode === 'delete') {
console.log('- 删除页面文件:')
viewFiles.forEach((f) => console.log(` · ${f}`))
} else {
console.log('- 不删除任何页面文件,仅屏蔽模块')
}
const confirm = await ask('\n确认执行这些修改吗(y/N): ')
if (confirm.toLowerCase() !== 'y') {
console.log('已取消操作。')
rl.close()
return
}
// 1) 修改 admin_menu.ts
let adminMenuContent = readTextFile(ADMIN_MENU_FILE)
if (adminMenuContent) {
const adminKeys = selectedModules.map((m) => m.key).filter((k) => PRUNE_CONFIG[k]?.adminModuleKey)
if (adminKeys.length > 0) {
for (const key of adminKeys) {
const cfg = PRUNE_CONFIG[key]
adminMenuContent = updateModuleKey(adminMenuContent, cfg.adminModuleKey)
adminMenuContent = commentComponentLines(adminMenuContent, cfg.adminComponentNames)
}
writeTextFile(ADMIN_MENU_FILE, adminMenuContent)
console.log('✅ 已更新 src/router/admin_menu.ts')
}
}
// 2) 修改 user_menu_ai.ts
let userMenuContent = readTextFile(USER_MENU_FILE)
if (userMenuContent) {
const userKeys = selectedModules.map((m) => m.key).filter((k) => PRUNE_CONFIG[k]?.userModuleKey)
if (userKeys.length > 0) {
for (const key of userKeys) {
const cfg = PRUNE_CONFIG[key]
userMenuContent = updateModuleKey(userMenuContent, cfg.userModuleKey)
userMenuContent = commentComponentLines(userMenuContent, cfg.userComponentNames)
}
writeTextFile(USER_MENU_FILE, userMenuContent)
console.log('✅ 已更新 src/router/user_menu_ai.ts')
}
}
// 3) 删除 .vue 页面文件(仅在 delete 模式下)
if (mode === 'delete') {
console.log('\n开始删除页面文件...')
for (const relative of viewFiles) {
const full = resolvePath(relative)
if (fs.existsSync(full)) {
fs.rmSync(full)
console.log(`🗑️ 已删除: ${relative}`)
} else {
console.log(`⚠️ 文件不存在,跳过: ${relative}`)
}
}
}
console.log('\n🎉 裁剪完成。建议执行以下操作检查:')
console.log('- 重新运行: npm run dev')
console.log('- 在浏览器中确认菜单和路由是否符合预期')
console.log('- 如需恢复,请使用 Git 回退或重新拷贝模板')
rl.close()
}
main().catch((err) => {
console.error('执行过程中发生错误:', err)
rl.close()
process.exit(1)
})

View File

@@ -14,3 +14,4 @@ export * from './ai'
// export * from './admin'
export * from './log'
export * from './knowledge'
export * from './kb'

View File

@@ -0,0 +1,131 @@
import { request } from '@/utils/hertz_request'
// 通用响应结构(与后端 HertzResponse 对齐)
export interface KbApiResponse<T> {
success: boolean
code: number
message: string
data: T
}
// 知识库条目
export interface KbItem {
id: number
title: string
modality: 'text' | 'code' | 'image' | 'audio' | 'video' | string
source_type: 'text' | 'file' | 'url' | string
chunk_count?: number
created_at?: string
updated_at?: string
created_chunk_count?: number
// 允许后端扩展字段
[key: string]: any
}
export interface KbItemListParams {
query?: string
page?: number
page_size?: number
}
export interface KbItemListData {
total: number
page: number
page_size: number
list: KbItem[]
}
// 语义搜索
export interface KbSearchParams {
q: string
k?: number
}
// 问答RAG
export interface KbQaPayload {
question: string
k?: number
}
export interface KbQaData {
answer: string
[key: string]: any
}
// 图谱查询参数(实体 / 关系)
export interface KbGraphListParams {
query?: string
page?: number
page_size?: number
// 关系检索可选参数
source?: number
target?: number
relation_type?: string
}
export const kbApi = {
// 知识库条目:列表
listItems(params?: KbItemListParams): Promise<KbApiResponse<KbItemListData>> {
return request.get('/api/kb/items/list/', { params })
},
// 语义搜索
search(params: KbSearchParams): Promise<KbApiResponse<any>> {
return request.get('/api/kb/search/', { params })
},
// 问答RAG
qa(payload: KbQaPayload): Promise<KbApiResponse<KbQaData>> {
return request.post('/api/kb/qa/', payload)
},
// 图谱:实体列表
listEntities(params?: KbGraphListParams): Promise<KbApiResponse<any>> {
return request.get('/api/kb/graph/entities/', { params })
},
// 图谱:关系列表
listRelations(params?: KbGraphListParams): Promise<KbApiResponse<any>> {
return request.get('/api/kb/graph/relations/', { params })
},
// 知识库条目创建JSON 文本)
createItemJson(payload: { title: string; modality?: string; source_type?: string; content?: string; metadata?: any }): Promise<KbApiResponse<KbItem>> {
return request.post('/api/kb/items/create/', payload)
},
// 知识库条目:创建(文件上传)
createItemFile(formData: FormData): Promise<KbApiResponse<KbItem>> {
return request.post('/api/kb/items/create/', formData)
},
// 图谱:创建实体
createEntity(payload: { name: string; type: string; properties?: any }): Promise<KbApiResponse<any>> {
return request.post('/api/kb/graph/entities/', payload)
},
// 图谱:更新实体
updateEntity(id: number, payload: { name?: string; type?: string; properties?: any }): Promise<KbApiResponse<any>> {
return request.put(`/api/kb/graph/entities/${id}/`, payload)
},
// 图谱:删除实体
deleteEntity(id: number): Promise<KbApiResponse<null>> {
return request.delete(`/api/kb/graph/entities/${id}/`)
},
// 图谱:创建关系
createRelation(payload: { source: number; target: number; relation_type: string; properties?: any; source_chunk?: number }): Promise<KbApiResponse<any>> {
return request.post('/api/kb/graph/relations/', payload)
},
// 图谱:删除关系
deleteRelation(id: number): Promise<KbApiResponse<null>> {
return request.delete(`/api/kb/graph/relations/${id}/`)
},
// 图谱:自动抽取实体与关系
extractGraph(payload: { text?: string; item_id?: number }): Promise<KbApiResponse<{ entities: number; relations: number }>> {
return request.post('/api/kb/graph/extract/', payload)
},
}

View File

@@ -103,6 +103,12 @@ export const userApi = {
return request.put('/api/auth/user/info/update/', data)
},
uploadAvatar: (file: File): Promise<ApiResponse<User>> => {
const formData = new FormData()
formData.append('avatar', file)
return request.upload('/api/auth/user/avatar/upload/', formData)
},
// 分配用户角色
assignRoles: (data: AssignRolesParams): Promise<ApiResponse<any>> => {
return request.post('/api/users/assign-roles/', data)

View File

@@ -67,6 +67,114 @@ export interface YoloModelListResponse {
}
}
// 数据集管理相关类型
export interface YoloDatasetSummary {
id: number
name: string
version?: string
root_folder_path: string
data_yaml_path: string
nc?: number
description?: string
created_at?: string
}
export interface YoloDatasetDetail extends YoloDatasetSummary {
names?: string[]
train_images_count?: number
train_labels_count?: number
val_images_count?: number
val_labels_count?: number
test_images_count?: number
test_labels_count?: number
}
export interface YoloDatasetSampleItem {
image: string
image_size?: number
label?: string
filename: string
}
// YOLO 训练任务相关类型
export type YoloTrainStatus =
| 'queued'
| 'running'
| 'canceling'
| 'completed'
| 'failed'
| 'canceled'
export interface YoloTrainDatasetOption {
id: number
name: string
version?: string
yaml: string
}
export interface YoloTrainVersionOption {
family: 'v8' | '11' | '12'
config_path: string
sizes: string[]
}
export interface YoloTrainOptionsResponse {
success: boolean
code?: number
message?: string
data?: {
datasets: YoloTrainDatasetOption[]
versions: YoloTrainVersionOption[]
}
}
export interface YoloTrainingJob {
id: number
dataset: number
dataset_name: string
model_family: 'v8' | '11' | '12'
model_size?: 'n' | 's' | 'm' | 'l' | 'x'
weight_path?: string
config_path?: string
status: YoloTrainStatus
logs_path?: string
runs_path?: string
best_model_path?: string
last_model_path?: string
progress: number
epochs: number
imgsz: number
batch: number
device: string
optimizer: 'SGD' | 'Adam' | 'AdamW' | 'RMSProp'
error_message?: string
created_at: string
started_at?: string | null
finished_at?: string | null
}
export interface StartTrainingPayload {
dataset_id: number
model_family: 'v8' | '11' | '12'
model_size?: 'n' | 's' | 'm' | 'l' | 'x'
epochs?: number
imgsz?: number
batch?: number
device?: string
optimizer?: 'SGD' | 'Adam' | 'AdamW' | 'RMSProp'
}
export interface YoloTrainLogsResponse {
success: boolean
code?: number
message?: string
data?: {
content: string
next_offset: number
finished: boolean
}
}
// YOLO检测API
export const yoloApi = {
// 执行YOLO检测
@@ -111,7 +219,8 @@ export const yoloApi = {
// 获取当前启用的YOLO模型信息
async getCurrentEnabledModel(): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
return request.get('/api/yolo/models/enabled/')
// 关闭全局错误提示,由调用方(如 YOLO 检测页面)自行处理“未启用模型”等业务文案
return request.get('/api/yolo/models/enabled/', { showError: false })
},
// 获取模型详情
@@ -196,6 +305,112 @@ export const yoloApi = {
return request.get('/api/yolo/stats/')
},
// 数据集管理相关接口
// 上传数据集
async uploadDataset(formData: FormData): Promise<{ success: boolean; data?: YoloDatasetDetail; message?: string }> {
return request.upload('/api/yolo/datasets/upload/', formData)
},
// 获取数据集列表
async getDatasets(): Promise<{ success: boolean; data?: YoloDatasetSummary[]; message?: string }> {
return request.get('/api/yolo/datasets/')
},
// 获取数据集详情
async getDatasetDetail(datasetId: number): Promise<{ success: boolean; data?: YoloDatasetDetail; message?: string }> {
return request.get(`/api/yolo/datasets/${datasetId}/`)
},
// 删除数据集
async deleteDataset(datasetId: number): Promise<{ success: boolean; message?: string }> {
return request.post(`/api/yolo/datasets/${datasetId}/delete/`)
},
// 获取数据集样本
async getDatasetSamples(
datasetId: number,
params: { split?: 'train' | 'val' | 'test'; limit?: number; offset?: number } = {}
): Promise<{
success: boolean
data?: { items: YoloDatasetSampleItem[]; total: number }
message?: string
}> {
return request.get(`/api/yolo/datasets/${datasetId}/samples/`, { params })
},
// YOLO 训练任务相关接口
// 获取训练选项(可用数据集与模型版本)
async getTrainOptions(): Promise<YoloTrainOptionsResponse> {
return request.get('/api/yolo/train/options/')
},
// 获取训练任务列表
async getTrainJobs(): Promise<{
success: boolean
code?: number
message?: string
data?: YoloTrainingJob[]
}> {
return request.get('/api/yolo/train/jobs/')
},
// 创建并启动训练任务
async startTrainJob(payload: StartTrainingPayload): Promise<{
success: boolean
code?: number
message?: string
data?: YoloTrainingJob
}> {
return request.post('/api/yolo/train/jobs/start/', payload)
},
// 获取训练任务详情
async getTrainJobDetail(id: number): Promise<{
success: boolean
code?: number
message?: string
data?: YoloTrainingJob
}> {
return request.get(`/api/yolo/train/jobs/${id}/`)
},
// 获取训练任务日志(分页读取)
async getTrainJobLogs(
id: number,
params: { offset?: number; max?: number } = {}
): Promise<YoloTrainLogsResponse> {
return request.get(`/api/yolo/train/jobs/${id}/logs/`, { params })
},
// 取消训练任务
async cancelTrainJob(id: number): Promise<{
success: boolean
code?: number
message?: string
data?: YoloTrainingJob
}> {
return request.post(`/api/yolo/train/jobs/${id}/cancel/`)
},
// 下载训练结果ZIP
async downloadTrainJobResult(id: number): Promise<{
success: boolean
code?: number
message?: string
data?: { url: string; size: number }
}> {
return request.get(`/api/yolo/train/jobs/${id}/download/`)
},
// 删除训练任务
async deleteTrainJob(id: number): Promise<{
success: boolean
code?: number
message?: string
}> {
return request.post(`/api/yolo/train/jobs/${id}/delete/`)
},
// 警告等级管理相关接口
// 获取警告等级列表
async getAlertLevels(): Promise<{ success: boolean; data?: AlertLevel[]; message?: string }> {

View File

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

Before

Width:  |  Height:  |  Size: 496 B

View File

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

View File

@@ -0,0 +1,85 @@
export type HertzModuleGroup = 'admin' | 'user'
export interface HertzModule {
key: string
label: string
group: HertzModuleGroup
description?: string
defaultEnabled: boolean
}
export const HERTZ_MODULES: HertzModule[] = [
{ key: 'admin.user-management', label: '管理端 · 用户管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.department-management', label: '管理端 · 部门管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.menu-management', label: '管理端 · 菜单管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.role-management', label: '管理端 · 角色管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.notification-management', label: '管理端 · 通知管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.log-management', label: '管理端 · 日志管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.knowledge-base', label: '管理端 · 文章管理', group: 'admin', defaultEnabled: true },
{ key: 'admin.yolo-model', label: '管理端 · YOLO 模型相关', group: 'admin', defaultEnabled: true },
{ key: 'user.system-monitor', label: '用户端 · 系统监控', group: 'user', defaultEnabled: true },
{ key: 'user.ai-chat', label: '用户端 · AI 助手', group: 'user', defaultEnabled: true },
{ key: 'user.yolo-detection', label: '用户端 · YOLO 检测', group: 'user', defaultEnabled: true },
{ key: 'user.live-detection', label: '用户端 · 实时检测', group: 'user', defaultEnabled: true },
{ key: 'user.detection-history', label: '用户端 · 检测历史', group: 'user', defaultEnabled: true },
{ key: 'user.alert-center', label: '用户端 · 告警中心', group: 'user', defaultEnabled: true },
{ key: 'user.notice-center', label: '用户端 · 通知中心', group: 'user', defaultEnabled: true },
{ key: 'user.knowledge-center', label: '用户端 · 文章中心', group: 'user', defaultEnabled: true },
{ key: 'user.kb-center', label: '用户端 · 知识库中心', group: 'user', defaultEnabled: true },
]
const LOCAL_STORAGE_KEY = 'hertz_enabled_modules'
export function getEnabledModuleKeys(): string[] {
const fallback = HERTZ_MODULES.filter(m => m.defaultEnabled).map(m => m.key)
if (typeof window === 'undefined') {
return fallback
}
try {
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY)
if (!stored) return fallback
const parsed = JSON.parse(stored)
if (Array.isArray(parsed)) {
const valid = parsed.filter((k): k is string => typeof k === 'string')
// 自动合并新增的默认启用模块,避免新模块在已有选择下被永久隐藏
const missingDefaults = HERTZ_MODULES
.filter(m => m.defaultEnabled && !valid.includes(m.key))
.map(m => m.key)
return [...valid, ...missingDefaults]
}
return fallback
} catch {
return fallback
}
}
export function setEnabledModuleKeys(keys: string[]): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(keys))
} catch {
// ignore
}
}
export function isModuleEnabled(moduleKey?: string, enabledKeys?: string[]): boolean {
if (!moduleKey) return true
const keys = enabledKeys ?? getEnabledModuleKeys()
return keys.indexOf(moduleKey) !== -1
}
export function getModulesByGroup(group: HertzModuleGroup): HertzModule[] {
return HERTZ_MODULES.filter(m => m.group === group)
}
export function hasModuleSelection(): boolean {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(LOCAL_STORAGE_KEY) !== null
} catch {
return false
}
}

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,4 +1,5 @@
import type { RouteRecordRaw } from "vue-router";
import { getEnabledModuleKeys, isModuleEnabled } from "@/config/hertz_modules";
// 角色权限枚举
export enum UserRole {
@@ -19,6 +20,7 @@ export interface AdminMenuItem {
roles?: UserRole[]; // 允许访问的角色,不设置则使用默认管理员角色
permission?: string; // 所需权限标识符
children?: AdminMenuItem[]; // 子菜单
moduleKey?: string;
}
// 🎯 统一配置中心 - 只需要在这里修改菜单配置
@@ -38,6 +40,7 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/user-management",
component: "UserManagement.vue",
permission: "system:user:list", // 需要用户列表权限
moduleKey: "admin.user-management",
},
{
key: "department-management",
@@ -45,7 +48,8 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
icon: "SettingOutlined",
path: "/admin/department-management",
component: "DepartmentManagement.vue",
permission: "system:dept:list", // 需要部门列表权限
permission: "system:dept:list", // 需要部门列表权限
moduleKey: "admin.department-management",
},
{
key: "menu-management",
@@ -54,6 +58,7 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/menu-management",
component: "MenuManagement.vue",
permission: "system:menu:list", // 需要菜单列表权限
moduleKey: "admin.menu-management",
},
{
key: "teacher",
@@ -62,6 +67,7 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/teacher",
component: "Role.vue",
permission: "system:role:list", // 需要角色列表权限
moduleKey: "admin.role-management",
},
{
key: "notification-management",
@@ -70,6 +76,7 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/notification-management",
component: "NotificationManagement.vue",
permission: "studio:notice:list", // 需要通知列表权限
moduleKey: "admin.notification-management",
},
{
key: "log-management",
@@ -78,15 +85,17 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/log-management",
component: "LogManagement.vue",
permission: "log.view_operationlog", // 查看操作日志权限
moduleKey: "admin.log-management",
},
{
key: "knowledge-base",
title: "知识库管理",
title: "文章管理",
icon: "DatabaseOutlined",
path: "/admin/knowledge-base",
component: "KnowledgeBaseManagement.vue",
path: "/admin/article-management",
component: "ArticleManagement.vue",
// 菜单访问权限:需要具备文章列表权限
permission: "system:knowledge:article:list",
moduleKey: "admin.knowledge-base",
},
{
key: "yolo-model",
@@ -95,6 +104,7 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
path: "/admin/yolo-model",
component: "ModelManagement.vue", // 默认显示模型管理页面
// 父菜单不设置权限,由子菜单的权限决定是否显示
moduleKey: "admin.yolo-model",
children: [
{
key: "model-management",
@@ -104,6 +114,20 @@ export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
component: "ModelManagement.vue",
permission: "system:yolo:model:list",
},
{
key: "dataset-management",
title: "数据集管理",
icon: "DatabaseOutlined",
path: "/admin/dataset-management",
component: "DatasetManagement.vue",
},
{
key: "yolo-train-management",
title: "YOLO训练",
icon: "HistoryOutlined",
path: "/admin/yolo-train",
component: "YoloTrainManagement.vue",
},
{
key: "alert-level-management",
title: "模型类别管理",
@@ -144,8 +168,10 @@ const COMPONENT_MAP: { [key: string]: () => Promise<any> } = {
'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"),
'ArticleManagement.vue': () => import("@/views/admin_page/ArticleManagement.vue"),
'ModelManagement.vue': () => import("@/views/admin_page/ModelManagement.vue"),
'DatasetManagement.vue': () => import("@/views/admin_page/DatasetManagement.vue"),
'YoloTrainManagement.vue': () => import("@/views/admin_page/YoloTrainManagement.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"),
@@ -154,8 +180,12 @@ const COMPONENT_MAP: { [key: string]: () => Promise<any> } = {
// 🚀 自动生成路由配置
function generateAdminRoutes(): RouteRecordRaw {
const children: RouteRecordRaw[] = [];
const enabledModuleKeys = getEnabledModuleKeys();
ADMIN_MENU_CONFIG.forEach(item => {
if (!isModuleEnabled(item.moduleKey, enabledModuleKeys)) {
return;
}
// 如果有子菜单,将子菜单作为独立的路由项
if (item.children && item.children.length > 0) {
// 为每个子菜单创建独立的路由
@@ -334,8 +364,13 @@ export const getFilteredMenuConfig = (userRoles: string[], userPermissions: stri
// 对 super_admin / system_admin 开放所有管理菜单(忽略权限字符串过滤)
const isPrivilegedAdmin = userRoles.includes('super_admin') || userRoles.includes('system_admin');
// 过滤菜单项 - 基于权限字符串检查
const enabledModuleKeys = getEnabledModuleKeys();
// 过滤菜单项 - 基于模块开关和权限字符串检查
const filteredMenus = ADMIN_MENU_CONFIG.filter(menuItem => {
if (!isModuleEnabled(menuItem.moduleKey, enabledModuleKeys)) {
return false;
}
console.log(`🔍 检查菜单项: ${menuItem.title} (${menuItem.key})`, {
hasPermission: !!menuItem.permission,
permission: menuItem.permission,

View File

@@ -3,6 +3,7 @@ import type { RouteRecordRaw } from "vue-router";
import { useUserStore } from "@/stores/hertz_user";
import { adminMenuRoutes, UserRole } from "./admin_menu";
import { userRoutes } from "./user_menu_ai";
import { hasModuleSelection } from "@/config/hertz_modules";
// 固定路由配置
const fixedRoutes: RouteRecordRaw[] = [
@@ -25,6 +26,15 @@ const fixedRoutes: RouteRecordRaw[] = [
requiresAuth: false,
},
},
{
path: "/template/modules",
name: "ModuleSetup",
component: () => import("@/views/ModuleSetup.vue"),
meta: {
title: "模块配置",
requiresAuth: false,
},
},
{
path: "/register",
name: "Register",
@@ -160,6 +170,16 @@ router.beforeEach((to, _from, next) => {
console.log('📋 用户信息:', userStore.userInfo);
console.log('🔄 重定向计数:', redirectCount);
// 模板模式:首次必须先完成模块选择
const isTemplateMode = import.meta.env.VITE_TEMPLATE_SETUP_MODE === 'true';
if (isTemplateMode && to.name !== "ModuleSetup") {
if (!hasModuleSelection()) {
console.log('🧩 模板模式开启,尚未选择模块,重定向到模块配置页');
next({ name: "ModuleSetup", query: { redirect: to.fullPath } });
return;
}
}
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 管理系统`;

View File

@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from 'vue-router'
import { defineAsyncComponent } from 'vue'
import { getEnabledModuleKeys, isModuleEnabled } from '@/config/hertz_modules'
export interface UserMenuConfig {
key: string
@@ -15,6 +16,7 @@ export interface UserMenuConfig {
roles?: string[]
[key: string]: any
}
moduleKey?: string
}
export interface MenuItem {
@@ -30,16 +32,23 @@ 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 } },
{ key: 'system-monitor', label: '系统监控', icon: 'DashboardOutlined', path: '/user/system-monitor', component: 'SystemMonitor.vue', meta: { title: '系统监控', requiresAuth: true }, moduleKey: 'user.system-monitor' },
{ key: 'ai-chat', label: 'AI助手', icon: 'MessageOutlined', path: '/user/ai-chat', component: 'AiChat.vue', meta: { title: 'AI助手', requiresAuth: true }, moduleKey: 'user.ai-chat' },
{ key: 'yolo-detection', label: 'YOLO检测', icon: 'ScanOutlined', path: '/user/yolo-detection', component: 'YoloDetection.vue', meta: { title: 'YOLO检测中心', requiresAuth: true }, moduleKey: 'user.yolo-detection' },
{ key: 'live-detection', label: '实时检测', icon: 'VideoCameraOutlined', path: '/user/live-detection', component: 'LiveDetection.vue', meta: { title: '实时检测', requiresAuth: true }, moduleKey: 'user.live-detection' },
{ key: 'detection-history', label: '检测历史', icon: 'HistoryOutlined', path: '/user/detection-history', component: 'DetectionHistory.vue', meta: { title: '检测历史记录', requiresAuth: true }, moduleKey: 'user.detection-history' },
{ key: 'alert-center', label: '告警中心', icon: 'ExclamationCircleOutlined', path: '/user/alert-center', component: 'AlertCenter.vue', meta: { title: '告警中心', requiresAuth: true }, moduleKey: 'user.alert-center' },
{ key: 'notice-center', label: '通知中心', icon: 'BellOutlined', path: '/user/notice', component: 'NoticeCenter.vue', meta: { title: '通知中心', requiresAuth: true }, moduleKey: 'user.notice-center' },
{ key: 'knowledge-center', label: '文章中心', icon: 'DatabaseOutlined', path: '/user/knowledge', component: 'ArticleCenter.vue', meta: { title: '文章中心', requiresAuth: true }, moduleKey: 'user.knowledge-center' },
{ key: 'kb-center', label: '知识库中心', icon: 'DatabaseOutlined', path: '/user/kb-center', component: 'KbCenter.vue', meta: { title: '知识库中心', requiresAuth: true }, moduleKey: 'user.kb-center' },
]
const enabledModuleKeys = getEnabledModuleKeys()
const effectiveUserMenuConfigs: UserMenuConfig[] = userMenuConfigs.filter(config =>
isModuleEnabled(config.moduleKey, enabledModuleKeys)
)
const explicitComponentMap: Record<string, any> = {
'index.vue': defineAsyncComponent(() => import('@/views/user_pages/index.vue')),
'Profile.vue': defineAsyncComponent(() => import('@/views/user_pages/Profile.vue')),
@@ -52,10 +61,11 @@ const explicitComponentMap: Record<string, any> = {
'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')),
'ArticleCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/ArticleCenter.vue')),
'KbCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/KbCenter.vue')),
}
export const userMenuItems: MenuItem[] = userMenuConfigs.map(config => ({
export const userMenuItems: MenuItem[] = effectiveUserMenuConfigs.map(config => ({
key: config.key,
label: config.label,
icon: config.icon,
@@ -76,10 +86,11 @@ const componentMap: Record<string, () => Promise<any>> = {
'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'),
'ArticleCenter.vue': () => import('@/views/user_pages/ArticleCenter.vue'),
'KbCenter.vue': () => import('@/views/user_pages/KbCenter.vue'),
}
const baseRoutes: RouteRecordRaw[] = userMenuConfigs.map(config => {
const baseRoutes: RouteRecordRaw[] = effectiveUserMenuConfigs.map(config => {
const route: RouteRecordRaw = {
path: config.path,
name: `User${config.key.charAt(0).toUpperCase() + config.key.slice(1)}`,
@@ -101,7 +112,7 @@ const baseRoutes: RouteRecordRaw[] = userMenuConfigs.map(config => {
const knowledgeDetailRoute: RouteRecordRaw = {
path: '/user/knowledge/:id',
name: 'UserKnowledgeDetail',
component: () => import('@/views/user_pages/KnowledgeDetail.vue'),
component: () => import('@/views/user_pages/ArticleDetail.vue'),
meta: { title: '文章详情', requiresAuth: true, hideInMenu: true }
}
@@ -148,14 +159,14 @@ export const generateComponentMap = () => {
if (config.children) processConfigs(config.children)
})
}
processConfigs(userMenuConfigs)
processConfigs(effectiveUserMenuConfigs)
return map
}
export const userComponentMap = generateComponentMap()
export const getFilteredUserMenuItems = (userRoles: string[], userPermissions: string[]): MenuItem[] => {
return userMenuConfigs
return effectiveUserMenuConfigs
.filter(config => {
// 隐藏菜单中不显示的项(如个人信息,只在用户下拉菜单中显示)
if (config.meta?.hideInMenu) return false

View File

@@ -6,6 +6,7 @@ import type { ChangePasswordParams } from '@/api/password'
import { roleApi } from '@/api/role'
import { initializeMenuMapping } from '@/utils/menu_mapping'
import { logoutUser } from '@/api/auth'
import { hasModuleSelection } from '@/config/hertz_modules'
// 用户信息接口
interface UserInfo {
@@ -69,8 +70,11 @@ export const useUserStore = defineStore('user', () => {
localStorage.setItem('userInfo', JSON.stringify(response.user_info))
}
// 获取用户菜单权限
await fetchUserMenuPermissions()
// 获取用户菜单权限(模板模式首次运行时跳过)
const isTemplateMode = import.meta.env.VITE_TEMPLATE_SETUP_MODE === 'true'
if (!isTemplateMode || hasModuleSelection()) {
await fetchUserMenuPermissions()
}
return response
} catch (error) {
@@ -142,9 +146,12 @@ export const useUserStore = defineStore('user', () => {
console.log('✅ 用户状态恢复成功')
console.log('👤 恢复的用户信息:', parsedUserInfo)
console.log('🔐 登录状态:', isLoggedIn.value)
// 获取用户菜单权限
await fetchUserMenuPermissions()
// 获取用户菜单权限(模板模式首次运行时跳过)
const isTemplateMode = import.meta.env.VITE_TEMPLATE_SETUP_MODE === 'true'
if (!isTemplateMode || hasModuleSelection()) {
await fetchUserMenuPermissions()
}
} catch (error) {
console.error('❌ 解析用户信息失败:', error)
clearAuth()
@@ -181,6 +188,14 @@ export const useUserStore = defineStore('user', () => {
}
try {
const adminRoleCodes = ['admin', 'system_admin', 'super_admin']
const hasAdminRole = userInfo.value.roles.some(role => adminRoleCodes.includes(role.role_code))
if (!hasAdminRole) {
userMenuPermissions.value = []
return []
}
// 获取用户所有角色的菜单权限
const allMenuPermissions = new Set<number>()

View File

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

View File

@@ -0,0 +1,149 @@
<template>
<div class="module-setup">
<a-card title="功能模块配置" class="module-setup-card">
<div class="module-setup-intro">
<p class="intro-title">使用说明</p>
<p>1本页面用于功能模块 DIY勾选需要启用的模块未勾选的模块将在菜单和路由中隐藏仅作为运行时屏蔽不影响源码文件</p>
<p>2配置保存成功后选择结果会以 <code>hertz_enabled_modules</code> 的形式保存在浏览器 Local Storage 下次执行 <code>npm run dev</code> 时如果存在该记录将直接进入系统而不再展示本配置页</p>
<p>3如需重新调整模块请打开浏览器开发者工具 <strong>Application</strong> <strong>Local Storage</strong> 选择当前站点删除键 <code>hertz_enabled_modules</code>然后刷新页面即可重新回到本页重新选择</p>
<a-alert
type="warning"
show-icon
message="一键裁剪(可选)"
description="在本页确认模块选择并关闭运行环境后,可在终端运行 npm run prune按提示对未勾选模块进行一键裁剪支持仅屏蔽或直接删除相关页面。"
/>
</div>
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="admin" tab="管理端模块">
<a-checkbox-group v-model:value="adminSelected" class="module-group">
<div
v-for="m in adminModules"
:key="m.key"
class="module-item"
>
<a-checkbox :value="m.key">
<span class="module-label">{{ m.label }}</span>
</a-checkbox>
</div>
</a-checkbox-group>
</a-tab-pane>
<a-tab-pane key="user" tab="用户端模块">
<a-checkbox-group v-model:value="userSelected" class="module-group">
<div
v-for="m in userModules"
:key="m.key"
class="module-item"
>
<a-checkbox :value="m.key">
<span class="module-label">{{ m.label }}</span>
</a-checkbox>
</div>
</a-checkbox-group>
</a-tab-pane>
</a-tabs>
<div class="module-setup-actions">
<a-button style="margin-right: 8px" @click="resetToDefault">恢复默认</a-button>
<a-button style="margin-right: 8px" @click="saveModules">保存配置并刷新</a-button>
<a-button type="primary" @click="saveModulesAndGoLogin">保存并跳转登录</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
HERTZ_MODULES,
getEnabledModuleKeys,
getModulesByGroup,
setEnabledModuleKeys,
type HertzModuleGroup,
} from '@/config/hertz_modules'
const activeKey = ref<HertzModuleGroup>('admin')
const adminModules = computed(() => getModulesByGroup('admin'))
const userModules = computed(() => getModulesByGroup('user'))
const adminSelected = ref<string[]>([])
const userSelected = ref<string[]>([])
const loadCurrentSelection = () => {
const enabled = getEnabledModuleKeys()
adminSelected.value = enabled.filter(k => k.indexOf('admin.') === 0)
userSelected.value = enabled.filter(k => k.indexOf('user.') === 0)
}
const resetToDefault = () => {
const defaultEnabled = HERTZ_MODULES.filter(m => m.defaultEnabled).map(m => m.key)
setEnabledModuleKeys(defaultEnabled)
loadCurrentSelection()
}
const saveModules = () => {
const merged = adminSelected.value.concat(userSelected.value)
setEnabledModuleKeys(merged)
if (typeof window !== 'undefined') {
window.location.reload()
}
}
const saveModulesAndGoLogin = () => {
const merged = adminSelected.value.concat(userSelected.value)
setEnabledModuleKeys(merged)
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
onMounted(() => {
loadCurrentSelection()
})
</script>
<style scoped lang="scss">
.module-setup {
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
background: #f5f5f5;
}
.module-setup-card {
width: 100%;
max-width: 960px;
}
.module-setup-intro {
margin-bottom: 16px;
color: #666;
font-size: 14px;
}
.intro-title {
margin-bottom: 4px;
font-weight: 600;
color: #333;
}
.module-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.module-item {
padding: 8px 0;
}
.module-label {
margin-left: 4px;
}
.module-setup-actions {
margin-top: 16px;
text-align: right;
}
</style>

View File

@@ -506,7 +506,7 @@ const quickActions = [
{ key: 'role', label: '角色管理', icon: DatabaseOutlined, gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
{ key: 'notification', label: '通知管理', icon: BellOutlined, gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ key: 'log', label: '日志管理', icon: FileSearchOutlined, gradient: 'linear-gradient(135deg, #30cfd0 0%, #330867 100%)' },
{ key: 'knowledge', label: '知识库管理', icon: BookOutlined, gradient: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)' },
{ key: 'knowledge', label: '文章管理', icon: BookOutlined, gradient: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)' },
{ key: 'model-management', label: '模型管理', icon: RobotOutlined, gradient: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)' },
{ key: 'alert-level-management', label: '类别管理', icon: WarningOutlined, gradient: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
{ key: 'alert-processing-center', label: '告警中心', icon: BellOutlined, gradient: 'linear-gradient(135deg, #ff6e7f 0%, #bfe9ff 100%)' },
@@ -594,8 +594,8 @@ const handleQuickAction = (action: string) => {
message.success('跳转到日志管理页面')
break
case 'knowledge':
router.push('/admin/knowledge-base')
message.success('跳转到知识库管理页面')
router.push('/admin/article-management')
message.success('跳转到文章管理页面')
break
case 'model-management':
router.push('/admin/model-management')

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,6 @@
@change="handleTableChange"
row-key="dept_id"
size="middle"
:scroll="{ x: 1200 }"
>
<!-- 部门名称列 -->
<template #bodyCell="{ column, record }">
@@ -325,49 +324,41 @@ const columns = [
title: '部门名称',
dataIndex: 'dept_name',
key: 'dept_name',
width: 200,
fixed: 'left'
},
{
title: '部门编码',
dataIndex: 'dept_code',
key: 'dept_code',
width: 120
key: 'dept_code'
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
width: 100
key: 'leader'
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 120
key: 'phone'
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180
key: 'email'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80
key: 'status'
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
width: 80
key: 'sort_order'
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right'
}
]
@@ -768,7 +759,7 @@ onMounted(() => {
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
padding: 10px 12px;
font-size: 13px;
letter-spacing: -0.1px;
}
@@ -781,7 +772,7 @@ onMounted(() => {
}
> td {
padding: 16px;
padding: 10px 12px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}

View File

@@ -874,7 +874,7 @@ function rowClassName(record: OperationLogItem) {
font-size: 13px;
}
:deep(.ant-input),
:deep(.ant-input-affix-wrapper),
:deep(.ant-input-number),
:deep(.ant-select-selector),
:deep(.ant-picker) {

View File

@@ -16,16 +16,23 @@
<!-- 操作栏 - 苹果风格 -->
<div class="action-bar">
<div class="search-section">
<a-input-search
<a-input
v-model:value="searchText"
placeholder="搜索菜单名称或编码"
style="width: 300px"
@search="handleSearchImmediate"
@pressEnter="handleSearchImmediate"
@input="handleSearch"
allow-clear
:loading="loading"
/>
<a-button
type="primary"
class="search-btn"
style="margin-left: 12px"
:loading="loading"
@click="handleSearchImmediate"
>
查询
</a-button>
</div>
<div class="button-section">
<a-button type="primary" @click="handleAdd" class="action-btn-primary">
@@ -48,7 +55,6 @@
:pagination="false"
row-key="menu_id"
size="middle"
:scroll="{ x: 1200 }"
childrenColumnName="children"
:default-expand-all-rows="false"
:expanded-row-keys="expandedRowKeys"
@@ -308,8 +314,6 @@ const modalMode = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const searchText = ref('')
// 菜单详情相关
const detailVisible = ref(false)
const detailLoading = ref(false)
@@ -379,60 +383,44 @@ const columns = [
{
title: '菜单名称',
dataIndex: 'menu_name',
key: 'menu_name',
width: 200,
fixed: 'left'
key: 'menu_name'
},
{
title: '菜单编码',
dataIndex: 'menu_code',
key: 'menu_code',
width: 150
key: 'menu_code'
},
{
title: '菜单类型',
dataIndex: 'menu_type',
key: 'menu_type',
width: 100
key: 'menu_type'
},
{
title: '路径',
dataIndex: 'path',
key: 'path',
width: 200
key: 'path'
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 80,
align: 'center'
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
width: 80,
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
align: 'center'
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 150
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
key: 'action'
}
]
@@ -664,8 +652,6 @@ const refreshData = async () => {
}
}
// 搜索防抖
let searchTimeout: NodeJS.Timeout | null = null
@@ -689,10 +675,7 @@ const handleSearchImmediate = () => {
// 搜索时不需要重新获取数据filteredMenuData会自动过滤
}
// 展开/收起菜单
const onExpand = (expanded: boolean, record: Menu) => {
if (expanded) {
if (!expandedRowKeys.value.includes(record.menu_id)) {
@@ -1001,7 +984,7 @@ onMounted(() => {
flex: 1;
:deep(.ant-input-search) {
.ant-input {
.ant-input-affix-wrapper {
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
@@ -1011,7 +994,7 @@ onMounted(() => {
border-color: #3b82f6;
}
&:focus {
&.ant-input-affix-wrapper-focused {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
@@ -1093,7 +1076,7 @@ onMounted(() => {
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
padding: 10px 12px;
font-size: 13px;
letter-spacing: -0.1px;
}
@@ -1106,7 +1089,7 @@ onMounted(() => {
}
> td {
padding: 16px;
padding: 10px 12px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}

View File

@@ -16,14 +16,22 @@
<!-- 操作栏 - 苹果风格 -->
<div class="action-bar">
<div class="search-section">
<a-input-search
<a-input
v-model:value="searchKeyword"
placeholder="搜索通知标题、内容..."
style="width: 300px"
@search="handleSearch"
@pressEnter="handleSearch"
allow-clear
/>
<a-button
type="primary"
class="search-btn"
style="margin-left: 12px"
:loading="loading"
@click="handleSearch"
>
查询
</a-button>
<a-select
v-model:value="statusFilter"
placeholder="状态筛选"
@@ -85,7 +93,7 @@
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="title-container">
<span class="title-text">{{ record.title }}</span>
<span class="title-text">{{ formatTitleShort(record.title) }}</span>
<span v-if="record.is_top" class="top-tag">置顶</span>
</div>
</template>
@@ -339,57 +347,59 @@ interface CommonResponse {
data?: any
}
// 表格列定义
// 表格列定义(为避免横向滚动,控制每列更紧凑的宽度)
const columns = [
{
title: '通知标题',
dataIndex: 'title',
key: 'title',
ellipsis: true
ellipsis: true,
width: 80
},
{
title: '通知类型',
dataIndex: 'notice_type',
key: 'notice_type',
width: 100
width: 80
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 80
width: 70
},
{
title: '是否置顶',
dataIndex: 'is_top',
key: 'is_top',
width: 80,
width: 70,
customRender: ({ text }) => text ? '是' : '否'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
width: 80
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
width: 180
width: 90,
customRender: ({ text }) => formatDateOnly(text as string)
},
{
title: '过期时间',
dataIndex: 'expire_time',
key: 'expire_time',
width: 180,
customRender: ({ text }) => text || '永久有效'
width: 90,
customRender: ({ text }) => (text ? formatDateOnly(text as string) : '永久有效')
},
{
title: '操作',
key: 'action',
width: 280,
fixed: 'right'
fixed: 'right',
width: 210
}
]
@@ -553,6 +563,20 @@ const getPriorityColor = (priority: number): string => {
return colorMap[priority] || 'default'
}
// 仅格式化为日期YYYY-MM-DD用于列表展示
const formatDateOnly = (val?: string): string => {
if (!val) return '-'
const str = String(val)
return str.length >= 10 ? str.slice(0, 10) : str
}
// 通知标题仅保留前4个字符多余用 ... 表示
const formatTitleShort = (val?: string): string => {
if (!val) return '-'
const str = String(val)
return str.length <= 4 ? str : str.slice(0, 4) + '...'
}
// 重置表单
const resetForm = () => {
if (formRef.value) {
@@ -614,13 +638,20 @@ const fetchNoticeList = async () => {
if (response.success && response.data) {
// 处理数据确保每个记录都有正确的id映射
let processedList = response.data.notices.map(notice => ({
...notice,
// 规范化状态,兼容 status_display 或字符串状态
status: getStatusCode(notice),
notice_id: Number(notice.notice_id),
id: Number(notice.notice_id) // 映射notice_id到id兼容现有代码并统一为数字
}))
let processedList = response.data.notices.map(notice => {
const createTime = notice.create_time || notice.created_at
const updateTime = notice.update_time || notice.updated_at
return {
...notice,
create_time: createTime,
update_time: updateTime,
// 规范化状态,兼容 status_display 或字符串状态
status: getStatusCode(notice),
notice_id: Number(notice.notice_id),
id: Number(notice.notice_id) // 映射notice_id到id兼容现有代码并统一为数字
}
})
// 如果后端不支持搜索或筛选,在前端进行客户端筛选
if (needsClientFilter) {
@@ -724,7 +755,14 @@ const fetchNoticeDetail = async (id: number) => {
detailLoading.value = true
const response = await request.get<NoticeDetailResponse>(`/api/notice/admin/detail/${id}/`)
if (response.success && response.data) {
notificationDetail.value = response.data
const data = response.data
const createTime = data.create_time || data.created_at
const updateTime = data.update_time || data.updated_at
notificationDetail.value = {
...data,
create_time: createTime,
update_time: updateTime
}
}
} catch (error) {
console.error('获取通知详情失败:', error)
@@ -1108,7 +1146,7 @@ onMounted(() => {
flex: 1;
:deep(.ant-input-search) {
.ant-input {
.ant-input-affix-wrapper {
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
@@ -1118,7 +1156,7 @@ onMounted(() => {
border-color: #3b82f6;
}
&:focus {
&.ant-input-affix-wrapper-focused {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
@@ -1240,7 +1278,7 @@ onMounted(() => {
font-weight: 600;
color: #1d1d1f;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
padding: 16px;
padding: 6px 8px;
font-size: 13px;
letter-spacing: -0.1px;
}
@@ -1253,7 +1291,7 @@ onMounted(() => {
}
> td {
padding: 16px;
padding: 6px 8px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}

View File

@@ -16,14 +16,22 @@
<!-- 操作栏 - 苹果风格 -->
<div class="action-bar">
<div class="search-section">
<a-input-search
<a-input
v-model:value="searchKeyword"
placeholder="搜索用户名、邮箱..."
style="width: 300px"
@search="handleSearch"
@pressEnter="handleSearch"
allow-clear
/>
<a-button
type="primary"
class="search-btn"
style="margin-left: 12px"
:loading="loading"
@click="handleSearch"
>
查询
</a-button>
<a-select
v-model:value="statusFilter"
placeholder="用户状态"
@@ -1153,7 +1161,7 @@ onMounted(() => {
flex: 1;
:deep(.ant-input-search) {
.ant-input {
.ant-input-affix-wrapper {
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.8);
@@ -1163,7 +1171,7 @@ onMounted(() => {
border-color: #3b82f6;
}
&:focus {
&.ant-input-affix-wrapper-focused {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -219,16 +219,25 @@
<a-spin size="large" />
<p>正在加载用户信息...</p>
</div>
<div v-else-if="currentUserInfo" class="user-info-content">
<!-- 用户头像区域 -->
<div class="user-avatar-section">
<a-avatar :size="80" :src="currentUserInfo.avatar" class="large-avatar">
<template #icon>
<UserOutlined />
</template>
{{ currentUserInfo.username?.charAt(0)?.toUpperCase() }}
</a-avatar>
<a-upload
:show-upload-list="false"
:before-upload="handleAdminAvatarBeforeUpload"
>
<a-avatar
:size="80"
:src="currentUserInfo.avatar || userStore.userInfo?.avatar"
class="large-avatar"
>
<template #icon>
<UserOutlined />
</template>
{{ currentUserInfo.username?.charAt(0)?.toUpperCase() }}
</a-avatar>
</a-upload>
<h3 class="user-display-name">
{{ currentUserInfo.real_name || currentUserInfo.username }}
</h3>
@@ -718,6 +727,53 @@ const fetchCurrentUserInfo = async () => {
}
}
const handleAdminAvatarBeforeUpload = async (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
message.error('只能上传图片文件作为头像')
return false
}
if (!isLt2M) {
message.error('头像图片大小不能超过 2MB')
return false
}
try {
const res: any = await userApi.uploadAvatar(file)
const updated = res?.data ?? res
const newAvatar = updated?.avatar || updated?.avatar_url
if (newAvatar) {
if (currentUserInfo.value) {
currentUserInfo.value.avatar = newAvatar
}
if (userInfoForm.value) {
(userInfoForm.value as any).avatar = newAvatar
}
if (userStore.userInfo) {
userStore.userInfo.avatar = newAvatar
localStorage.setItem('userInfo', JSON.stringify(userStore.userInfo))
}
const ok = typeof res?.success === 'boolean' ? res.success : true
if (ok) {
message.success(res?.message || '头像上传成功')
} else {
message.error(res?.message || '头像上传失败')
}
} else {
message.error(res?.message || '头像上传失败:未返回头像地址')
}
} catch (error: any) {
message.error(error?.message || '头像上传失败,请稍后重试')
}
return false
}
// 开始编辑用户信息
const startEditUserInfo = () => {
if (currentUserInfo.value) {
@@ -742,7 +798,13 @@ const handleUserInfoSave = async () => {
console.log('🔄 开始保存用户信息:', userInfoForm.value)
const response = await userApi.updateUserInfo(userInfoForm.value)
const payload: any = { ...userInfoForm.value }
// 头像在单独的上传接口中更新,这里不再提交 avatar 字段,避免 URL 校验报错
if ('avatar' in payload) {
delete payload.avatar
}
const response = await userApi.updateUserInfo(payload)
console.log('📋 更新用户信息API响应:', response)
if (response.success && response.data) {

View File

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

View File

@@ -57,11 +57,27 @@
</a-form-item>
<a-form-item label="真实姓名" name="real_name">
<a-input v-model:value="form.real_name" placeholder="请输入真实姓名" size="large" />
<a-input
v-model:value="form.real_name"
placeholder="请输入真实姓名"
size="large"
>
<template #prefix>
<IdcardOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号" size="large" />
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
size="large"
>
<template #prefix>
<PhoneOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item
@@ -125,7 +141,9 @@ import { useI18n } from 'vue-i18n'
import {
UserOutlined,
LockOutlined,
MailOutlined
MailOutlined,
IdcardOutlined,
PhoneOutlined,
} from '@ant-design/icons-vue'
import { registerUser } from '@/api/auth'

View File

@@ -140,7 +140,17 @@
</div>
<div class="message-content">
<div class="bubble">
<div class="content" v-html="renderContent(m.content)"></div>
<div class="content">
<div v-if="m.id === loadingMessageId && m.role === 'assistant'" class="typing-indicator">
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
</div>
<div v-else v-html="renderContent(m.content)"></div>
</div>
<div class="time">{{ formatTime(m.created_at) }}</div>
</div>
</div>
@@ -158,28 +168,28 @@
<div class="composer" v-if="currentChatId">
<div class="input-container">
<a-textarea
v-model:value="input"
:rows="3"
placeholder="输入你的问题..."
:disabled="!currentChatId || sending"
@pressEnter="onEnterSend"
<a-textarea
v-model:value="input"
:rows="3"
placeholder="输入你的问题..."
:disabled="!currentChatId"
@pressEnter="onEnterSend"
@keydown="onComposerKeydown"
class="message-input"
/>
<div class="input-actions">
<a-button
type="primary"
:disabled="!canSend"
:loading="sending"
@click="send"
class="send-btn"
size="large"
>
<template #icon>
<SendOutlined />
<SendOutlined />
</template>
发送消息
</a-button>
</a-button>
</div>
</div>
</div>
@@ -250,7 +260,17 @@
</div>
<div class="message-content">
<div class="bubble">
<div class="content" v-html="renderContent(m.content)"></div>
<div class="content">
<div v-if="m.id === loadingMessageId && m.role === 'assistant'" class="typing-indicator">
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
</div>
<div v-else v-html="renderContent(m.content)"></div>
</div>
<div class="time">{{ formatTime(m.created_at) }}</div>
</div>
</div>
@@ -272,7 +292,7 @@
v-model:value="input"
:rows="3"
placeholder="输入你的问题..."
:disabled="!currentChatId || sending"
:disabled="!currentChatId"
@pressEnter="onEnterSend"
class="message-input"
/>
@@ -280,7 +300,6 @@
<a-button
type="primary"
:disabled="!canSend"
:loading="sending"
@click="send"
class="send-btn"
size="large"
@@ -382,7 +401,17 @@
</div>
<div class="message-content">
<div class="bubble">
<div class="content" v-html="renderContent(m.content)"></div>
<div class="content">
<div v-if="m.id === loadingMessageId && m.role === 'assistant'" class="typing-indicator">
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-circle"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
<div class="typing-shadow"></div>
</div>
<div v-else v-html="renderContent(m.content)"></div>
</div>
<div class="time">{{ formatTime(m.created_at) }}</div>
</div>
</div>
@@ -404,7 +433,7 @@
v-model:value="input"
:rows="3"
placeholder="输入你的问题..."
:disabled="!currentChatId || sending"
:disabled="!currentChatId"
@pressEnter="onEnterSend"
class="message-input"
/>
@@ -412,7 +441,6 @@
<a-button
type="primary"
:disabled="!canSend"
:loading="sending"
@click="send"
class="send-btn"
size="large"
@@ -541,11 +569,20 @@ const renaming = ref(false)
let renameTargetId: number | null = null
const messagesEl = ref<HTMLElement | null>(null)
const loadingMessageId = ref<number | null>(null)
const canSend = computed(() => !!currentChatId.value && !!input.value.trim() && !sending.value)
const canSend = computed(() => !!currentChatId.value && !!input.value.trim())
const input = ref('')
const searchRef = ref<any>(null)
let tempMessageId = -1
const createLocalMessage = (role: 'user' | 'assistant', content: string): AIChatMessage => ({
id: tempMessageId--,
role,
content,
created_at: new Date().toISOString(),
})
const fetchChats = async () => {
loadingChats.value = true
try {
@@ -691,18 +728,40 @@ const deleteChat = async (id: number) => {
const send = async () => {
if (!canSend.value || !currentChatId.value) return
const content = input.value.trim()
sending.value = true
// 本地先插入用户消息
const userMsg = createLocalMessage('user', content)
messages.value.push(userMsg)
input.value = ''
await nextTick(); scrollToBottom()
// 本地插入 AI 加载中消息
const loadingMsg = createLocalMessage('assistant', 'AI 正在思考中,请稍候...')
messages.value.push(loadingMsg)
const loadingId = loadingMsg.id
loadingMessageId.value = loadingId
await nextTick(); scrollToBottom()
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 = ''
// 移除加载中消息
const idx = messages.value.findIndex(m => m.id === loadingId)
if (idx !== -1) messages.value.splice(idx, 1)
// 追加真正的 AI 回复
messages.value.push(res.data.ai_message)
await nextTick(); scrollToBottom(); fetchChats()
} else {
const idx = messages.value.findIndex(m => m.id === loadingId)
if (idx !== -1) messages.value.splice(idx, 1)
message.error(res.message || '发送失败')
}
} catch (e: any) { message.error(e?.message || '网络错误') }
finally { sending.value = false }
} catch (e: any) {
const idx = messages.value.findIndex(m => m.id === loadingId)
if (idx !== -1) messages.value.splice(idx, 1)
message.error(e?.message || '网络错误')
}
loadingMessageId.value = null
}
const formatTime = (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm:ss')
@@ -1006,6 +1065,61 @@ onUnmounted(() => {
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-primary, #1e293b);
// AI 正在思考加载动画(来自 Uiverse.io aaronross1适配聊天气泡
.typing-indicator {
width: 60px;
height: 30px;
position: relative;
z-index: 1;
}
.typing-circle {
width: 8px;
height: 8px;
position: absolute;
border-radius: 50%;
background-color: #000;
left: 15%;
transform-origin: 50%;
animation: typing-circle7124 0.5s alternate infinite ease;
}
.typing-circle:nth-child(2) {
left: 45%;
animation-delay: 0.2s;
}
.typing-circle:nth-child(3) {
left: auto;
right: 15%;
animation-delay: 0.3s;
}
.typing-shadow {
width: 5px;
height: 4px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.2);
position: absolute;
top: 30px;
transform-origin: 50%;
z-index: 0;
left: 15%;
filter: blur(1px);
animation: typing-shadow046 0.5s alternate infinite ease;
}
.typing-shadow:nth-child(4) {
left: 45%;
animation-delay: 0.2s;
}
.typing-shadow:nth-child(5) {
left: auto;
right: 15%;
animation-delay: 0.3s;
}
}
.time {
@@ -1487,4 +1601,39 @@ onUnmounted(() => {
}
}
@keyframes typing-circle7124 {
0% {
top: 20px;
height: 5px;
border-radius: 50px 50px 25px 25px;
transform: scaleX(1.7);
}
40% {
height: 8px;
border-radius: 50%;
transform: scaleX(1);
}
100% {
top: 0%;
}
}
@keyframes typing-shadow046 {
0% {
transform: scaleX(1.5);
}
40% {
transform: scaleX(1);
opacity: 0.7;
}
100% {
transform: scaleX(0.2);
opacity: 0.4;
}
}
</style>

View File

@@ -3,7 +3,7 @@
<div class="page-header">
<h1 class="page-title">
<BookOutlined class="title-icon" />
知识库中心
文章中心
</h1>
<p class="page-description">探索智能知识提升工作效率</p>
</div>
@@ -39,7 +39,7 @@
<div class="panel-icon">
<BookOutlined />
</div>
<h3 class="panel-title">知识分类</h3>
<h3 class="panel-title">文章分类</h3>
</div>
<div class="panel-content">
<a-spin :spinning="categoryLoading">
@@ -64,7 +64,7 @@
<FileTextOutlined />
</div>
<div class="title-group">
<h3 class="panel-title">知识库</h3>
<h3 class="panel-title">文章列表</h3>
<span class="panel-subtitle">发现更多精彩内容</span>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,24 @@
<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>
<a-upload
:show-upload-list="false"
:before-upload="handleAvatarBeforeUpload"
>
<a-avatar
:size="100"
class="avatar"
:src="userForm.avatar || userStore.userInfo?.avatar"
>
<template #icon>
<UserOutlined />
</template>
</a-avatar>
</a-upload>
<div class="avatar-info">
<div class="username">{{ userForm.username || '用户' }}</div>
<div class="user-role">普通用户</div>
<div class="avatar-hint">点击头像更换图片</div>
</div>
</div>
</div>
@@ -57,9 +67,9 @@
<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
<a-input
v-model:value="userForm.username"
disabled
class="form-input"
>
<template #prefix>
@@ -70,8 +80,8 @@
</a-col>
<a-col :span="12">
<a-form-item label="邮箱地址" name="email" class="form-item">
<a-input
v-model:value="userForm.email"
<a-input
v-model:value="userForm.email"
class="form-input"
placeholder="请输入邮箱地址"
>
@@ -92,8 +102,8 @@
<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"
<a-input
v-model:value="userForm.real_name"
class="form-input"
placeholder="请输入真实姓名"
>
@@ -105,8 +115,8 @@
</a-col>
<a-col :span="12">
<a-form-item label="手机号码" name="phone" class="form-item">
<a-input
v-model:value="userForm.phone"
<a-input
v-model:value="userForm.phone"
class="form-input"
placeholder="请输入手机号码"
>
@@ -127,8 +137,8 @@
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="性别" name="gender" class="form-item">
<a-select
v-model:value="userForm.gender"
<a-select
v-model:value="userForm.gender"
class="form-select"
placeholder="请选择性别"
>
@@ -140,8 +150,8 @@
</a-col>
<a-col :span="12">
<a-form-item label="生日" name="birthday" class="form-item">
<a-date-picker
v-model:value="userForm.birthday"
<a-date-picker
v-model:value="userForm.birthday"
class="form-date-picker"
placeholder="请选择生日"
style="width: 100%"
@@ -153,9 +163,9 @@
</div>
<div class="form-actions">
<a-button
type="primary"
html-type="submit"
<a-button
type="primary"
html-type="submit"
:loading="loading"
class="submit-btn"
size="large"
@@ -165,7 +175,7 @@
</template>
保存更改
</a-button>
<a-button
<a-button
@click="resetForm"
class="reset-btn"
size="large"
@@ -186,27 +196,32 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
UserOutlined,
SettingOutlined,
IdcardOutlined,
import {
UserOutlined,
SettingOutlined,
IdcardOutlined,
CalendarOutlined,
SaveOutlined,
ReloadOutlined,
MailOutlined,
PhoneOutlined
PhoneOutlined,
} from '@ant-design/icons-vue'
import { userApi, type User } from '@/api/user'
import { useUserStore } from '@/stores/hertz_user'
import dayjs from 'dayjs'
const loading = ref(false)
const avatarUploading = ref(false)
const userStore = useUserStore()
const userForm = ref<Partial<User>>({
username: '',
email: '',
real_name: '',
phone: '',
avatar: '',
gender: 0,
birthday: ''
birthday: '',
})
const fetchUserInfo = async () => {
@@ -215,7 +230,7 @@ const fetchUserInfo = async () => {
if (response.success) {
userForm.value = {
...response.data,
birthday: response.data.birthday ? dayjs(response.data.birthday) : undefined
birthday: response.data.birthday ? dayjs(response.data.birthday) : undefined,
}
}
} catch (error) {
@@ -227,11 +242,18 @@ const fetchUserInfo = async () => {
const handleSubmit = async () => {
loading.value = true
try {
const submitData = {
const submitData: any = {
...userForm.value,
birthday: userForm.value.birthday ? dayjs(userForm.value.birthday).format('YYYY-MM-DD') : undefined
birthday: userForm.value.birthday
? dayjs(userForm.value.birthday as any).format('YYYY-MM-DD')
: undefined,
}
// 头像通过单独的上传接口处理,这里不再提交 avatar 字段,避免 URL 校验报错
if ('avatar' in submitData) {
delete submitData.avatar
}
const response = await userApi.updateUserInfo(submitData)
if (response.success) {
message.success('个人信息更新成功!')
@@ -255,20 +277,69 @@ const resetForm = async () => {
}
}
const handleAvatarBeforeUpload = async (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
message.error('只能上传图片文件作为头像')
return false
}
if (!isLt2M) {
message.error('头像图片大小不能超过 2MB')
return false
}
avatarUploading.value = true
try {
const res: any = await userApi.uploadAvatar(file)
// 兼容两种返回结构:
// 1) 标准 { success, message, data: { avatar, ... } }
// 2) 直接返回用户对象 { avatar, ... }
const updatedUser = res?.data ?? res
// 后端返回字段为 data.avatar_url这里兼容 avatar 和 avatar_url
const newAvatar = updatedUser?.avatar || updatedUser?.avatar_url
if (newAvatar) {
// 更新当前页面表单
userForm.value.avatar = newAvatar
// 更新全局用户 store导航栏头像立即刷新
if (userStore.userInfo) {
userStore.userInfo.avatar = newAvatar
localStorage.setItem('userInfo', JSON.stringify(userStore.userInfo))
}
const ok = typeof res?.success === 'boolean' ? res.success : true
if (ok) {
message.success(res?.message || '头像上传成功')
} else {
message.error(res?.message || '头像上传失败')
}
} else {
message.error(res?.message || '头像上传失败:未返回头像地址')
}
} catch (error: any) {
message.error(error?.message || '头像上传失败,请稍后重试')
} finally {
avatarUploading.value = false
}
// 阻止 a-upload 自己提交,由我们手动处理
return false
}
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;
@@ -295,7 +366,6 @@ onMounted(() => {
}
}
// 用户信息卡片
.user-info-section {
max-width: 1200px;
margin: 0 auto 24px;
@@ -315,6 +385,7 @@ onMounted(() => {
.avatar {
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border: 3px solid var(--theme-card-border, #e5e7eb);
cursor: pointer;
}
.avatar-info {
@@ -330,12 +401,17 @@ onMounted(() => {
font-size: 14px;
font-weight: 500;
}
.avatar-hint {
margin-top: 4px;
font-size: 12px;
color: var(--theme-text-secondary, #94a3b8);
}
}
}
}
}
// 资料表单容器
.profile-container {
max-width: 1200px;
margin: 0 auto;
@@ -347,7 +423,6 @@ onMounted(() => {
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);
@@ -415,66 +490,6 @@ onMounted(() => {
.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);
}
}
}
}
}
}
@@ -487,81 +502,13 @@ onMounted(() => {
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);
}
}
.submit-btn,
.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%;
}
}
}
}

View File

@@ -918,7 +918,7 @@ import {
} from '@ant-design/icons-vue'
import { yoloApi, detectionHistoryApi, type YoloDetection, type YoloModel } from '@/api/yolo'
import { useUserStore } from '@/stores/hertz_user'
import { getFullFileUrl } from '@/utils/hertz_url'
import { getFullFileUrl, isImageFile, isVideoFile } from '@/utils/hertz_url'
// 布局模式
const layoutMode = ref<'classic' | 'vertical' | 'grid'>('classic')
@@ -1033,14 +1033,31 @@ interface DetectionResult {
// 上传前处理
const beforeUpload = (file: any) => {
const isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
const mimeType = file.type || ''
const fileName = file.name || ''
const isImageByMime = mimeType.startsWith('image/')
const isVideoByMime = mimeType.startsWith('video/')
const isImageByExt = isImageFile(fileName)
const isVideoByExt = isVideoFile(fileName)
const isImage = isImageByMime || (!isVideoByMime && isImageByExt)
const isVideo = isVideoByMime || (!isImageByMime && isVideoByExt)
if (!isImage && !isVideo) {
message.error('只能上传图片或视频文件!')
return false
}
// 如果浏览器没有提供 MIME 类型,根据判断结果补一个,方便后续逻辑使用
if (!file.type) {
if (isVideo) {
file.type = 'video/*'
} else if (isImage) {
file.type = 'image/*'
}
}
// 图片文件大小限制
if (isImage) {
const isLt10M = file.size / 1024 / 1024 < 10
@@ -1102,7 +1119,7 @@ const startDetection = async () => {
}
if (!currentModel.value) {
message.error('当前没有可用的检测模型,请稍后再试')
message.error('当前未启用 YOLO 检测模型,请先在模型管理中启用模型后再使用此功能')
return
}
@@ -1142,32 +1159,25 @@ const startDetection = async () => {
console.log('检测API响应:', response)
if (response.data) {
// 构建完整的图片URL统一通过工具函数处理
// 直接使用后端返回的URL地址不再通过getFullFileUrl处理
console.log('🔍 原始URL数据:', {
original_file_url: response.data.original_file_url,
result_file_url: response.data.result_file_url
})
let originalImageUrl = getFullFileUrl(response.data.original_file_url)
let resultImageUrl = getFullFileUrl(response.data.result_file_url)
let originalImageUrl = response.data.original_file_url
let resultImageUrl = response.data.result_file_url
console.log('🖼️ 构建后的图片URL:', {
console.log('🖼️ 直接使用的URL地址:', {
original: originalImageUrl,
result: resultImageUrl
})
// 测试URL是否可访问
console.log('🧪 测试图片URL可访问性...')
fetch(originalImageUrl, { method: 'HEAD' })
.then(res => console.log('✅ 原图URL可访问:', res.status))
.catch(err => console.error('❌ 原图URL不可访问:', err))
fetch(resultImageUrl, { method: 'HEAD' })
.then(res => console.log('✅ 结果图URL可访问:', res.status))
.catch(err => console.error('❌ 结果图URL不可访问:', err))
// 判断文件类型
const fileType = actualFile.type.startsWith('video/') ? 'video' : 'image'
// 判断文件类型(同时参考 MIME 和文件扩展名,避免视频被识别成图片)
const actualMimeType = (actualFile as any).type || ''
const actualName = (actualFile as any).name || file.name || ''
const isVideoFileType = actualMimeType.startsWith('video/') || isVideoFile(actualName)
const fileType: 'image' | 'video' = isVideoFileType ? 'video' : 'image'
// 对于视频文件使用后端返回的URL对于图片文件使用本地预览URL
const displayImageUrl = fileType === 'video' ? originalImageUrl : imageUrl
@@ -1197,31 +1207,60 @@ const startDetection = async () => {
timestamp: Date.now() // 添加时间戳
}
// 预加载图片,确保能正确显示
if (resultImageUrl) {
const img = new Image()
img.onload = () => {
result.resultImageLoaded = true
// 预加载图片,确保能正确显示(仅针对图片类型,视频不使用 Image 预加载)
if (fileType === 'image') {
// 创建Promise数组来等待所有图片加载完成
const loadPromises: Promise<void>[] = []
if (resultImageUrl) {
const resultPromise = new Promise<void>((resolve) => {
const img = new Image()
img.onload = () => {
result.resultImageLoaded = true
console.log('✅ 结果图片预加载成功:', resultImageUrl)
resolve()
}
img.onerror = () => {
result.resultImageError = true
console.error('❌ 结果图片预加载失败:', resultImageUrl)
resolve()
}
img.src = resultImageUrl
})
loadPromises.push(resultPromise)
}
img.onerror = () => {
result.resultImageError = true
if (originalImageUrl) {
const originalPromise = new Promise<void>((resolve) => {
const img = new Image()
img.onload = () => {
result.originalImageLoaded = true
console.log('✅ 原图片预加载成功:', originalImageUrl)
resolve()
}
img.onerror = () => {
result.originalImageError = true
console.error('❌ 原图片预加载失败:', originalImageUrl)
resolve()
}
img.src = originalImageUrl
})
loadPromises.push(originalPromise)
}
img.src = resultImageUrl
// 等待所有图片预加载完成后再添加到结果数组
Promise.all(loadPromises).then(() => {
console.log('📸 所有图片预加载完成,添加到结果数组')
detectionResults.value.unshift(result)
})
} else if (fileType === 'video') {
// 对于视频文件直接设置加载状态为true让视频元素自己处理加载
result.originalImageLoaded = true
result.resultImageLoaded = true
console.log('📹 视频文件直接设置加载状态为true')
// 视频直接添加到结果数组
detectionResults.value.unshift(result)
}
if (originalImageUrl) {
const img = new Image()
img.onload = () => {
result.originalImageLoaded = true
}
img.onerror = () => {
result.originalImageError = true
}
img.src = originalImageUrl
}
// 将新结果添加到数组开头,确保最新结果显示在最上面
detectionResults.value.unshift(result)
console.log('检测结果:', result)
// 检查警告级别并显示警告框
@@ -1500,7 +1539,7 @@ const retryImageLoad = (result: DetectionResult, type: 'original' | 'result') =>
if (type === 'original') {
result.originalImageError = false
result.originalImageLoaded = false
} else {
} else {
result.resultImageError = false
result.resultImageLoaded = false
}
@@ -1509,79 +1548,58 @@ const retryImageLoad = (result: DetectionResult, type: 'original' | 'result') =>
console.log('🔄 重试加载媒体:', url)
// 先测试URL是否可访问
fetch(url, { method: 'HEAD' })
.then(response => {
console.log('📡 URL测试响应:', response.status, response.statusText)
if (response.ok) {
// URL可访问尝试加载图片
const img = new Image()
const img = new Image()
img.onload = () => {
if (type === 'original') {
result.originalImageLoaded = true
} else {
result.resultImageLoaded = true
}
console.log('✅ 图片重试加载成功:', url)
message.success('图片加载成功')
}
img.onload = () => {
if (type === 'original') {
result.originalImageLoaded = true
} else {
result.resultImageLoaded = true
}
console.log('✅ 图片重试加载成功:', url)
message.success('图片加载成功')
}
img.onerror = () => {
if (type === 'original') {
result.originalImageError = true
} else {
result.resultImageError = true
}
console.error('❌ 图片重试加载失败:', url)
message.error('图片加载失败')
}
img.onerror = () => {
if (type === 'original') {
result.originalImageError = true
} else {
result.resultImageError = true
}
console.error('❌ 图片重试加载失败:', url)
message.error('图片加载失败')
}
img.src = url
} else {
console.error('❌ URL不可访问:', response.status, response.statusText)
if (type === 'original') {
result.originalImageError = true
} else {
result.resultImageError = true
}
message.error(`图片URL不可访问 (${response.status})`)
}
})
.catch(error => {
console.error('❌ URL测试失败:', error)
if (type === 'original') {
result.originalImageError = true
} else {
result.resultImageError = true
}
message.error('网络连接失败')
})
img.src = url
}
// 加载当前启用的YOLO模型
const loadCurrentModel = async () => {
try {
console.log('正在加载当前启用的YOLO模型...')
const response = await yoloApi.getCurrentEnabledModel()
const response: any = await yoloApi.getCurrentEnabledModel()
console.log('API响应:', response)
if (response.success && response.data) {
if (response && response.success && response.data) {
currentModel.value = response.data
console.log('加载到的当前模型:', currentModel.value)
message.success(`已加载模型: ${currentModel.value.name} v${currentModel.value.version}`)
} else {
console.error('加载当前模型失败:', response?.message || '未知错误')
console.warn('当前没有可用的YOLO模型:', response?.message || '未返回模型数据')
currentModel.value = null
// 不显示警告消息,避免干扰用户体验
console.warn('当前没有可用的YOLO模型请联系管理员启用模型')
message.warning(response?.message || '当前未启用 YOLO 检测模型,请先在模型管理中启用模型后再使用此功能')
}
} catch (error) {
} catch (error: any) {
console.error('加载当前模型时出错:', error)
currentModel.value = null
// 不显示错误消息,避免干扰用户体验
console.warn('加载模型失败,请检查网络连接')
const status = error?.response?.status
const backendMessage = error?.response?.data?.message
if (status === 404) {
message.warning(backendMessage || '当前未启用 YOLO 检测模型,请先在模型管理中启用模型后再使用此功能')
} else {
message.error(backendMessage || '加载 YOLO 模型失败,请稍后重试')
}
}
}

View File

@@ -60,12 +60,12 @@
<!-- 用户信息下拉菜单 -->
<a-dropdown placement="bottomRight" class="header-item">
<a-button type="text" class="user-info-btn">
<a-avatar :src="userInfo?.avatar" :size="32">
<a-avatar :src="userStore.userInfo?.avatar" :size="32">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="username">{{ userInfo?.real_name || userInfo?.username }}</span>
<span class="username">{{ userStore.userInfo?.real_name || userStore.userInfo?.username }}</span>
<DownOutlined />
</a-button>
<template #overlay>
@@ -3308,8 +3308,8 @@ const initMap = async () => {
// 检查容器是否存在
const container = document.getElementById('tencent-map-container')
if (!container) {
console.error('地图容器不存在')
message.error('地图容器不存在')
// 在非仪表盘或容器尚未渲染时,静默跳过初始化,避免在其他页面弹出错误
console.warn('地图容器不存在,跳过地图初始化')
return
}
@@ -4160,9 +4160,9 @@ onMounted(() => {
// 初始化语音合成
initSpeechSynthesis()
// 初始化地图(延迟执行,确保 DOM 已渲染)
// 初始化地图(延迟执行,确保 DOM 已渲染,仅在首页生效
setTimeout(() => {
initMap()
checkAndInitMap()
}, 500)
// 自动检测位置并查询天气(延迟执行,避免影响页面加载)