更新
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1
hertz_server_diango_ui/components.d.ts
vendored
1
hertz_server_diango_ui/components.d.ts
vendored
@@ -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']
|
||||
|
||||
19
hertz_server_diango_ui/package-lock.json
generated
19
hertz_server_diango_ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
392
hertz_server_diango_ui/scripts/prune-modules.mjs
Normal file
392
hertz_server_diango_ui/scripts/prune-modules.mjs
Normal 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)
|
||||
})
|
||||
@@ -14,3 +14,4 @@ export * from './ai'
|
||||
// export * from './admin'
|
||||
export * from './log'
|
||||
export * from './knowledge'
|
||||
export * from './kb'
|
||||
|
||||
131
hertz_server_diango_ui/src/api/kb.ts
Normal file
131
hertz_server_diango_ui/src/api/kb.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br>
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
|
||||
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
|
||||
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
|
||||
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
|
||||
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
|
||||
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
|
||||
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ msg: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
85
hertz_server_diango_ui/src/config/hertz_modules.ts
Normal file
85
hertz_server_diango_ui/src/config/hertz_modules.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 会话与消息类型
|
||||
export interface AIChatItem {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
latest_message?: string
|
||||
}
|
||||
|
||||
export interface AIChatDetail {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AIChatMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatListData {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
chats: AIChatItem[]
|
||||
}
|
||||
|
||||
export interface ChatDetailData {
|
||||
chat: AIChatDetail
|
||||
messages: AIChatMessage[]
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
user_message: AIChatMessage
|
||||
ai_message: AIChatMessage
|
||||
}
|
||||
|
||||
export const aiApi = {
|
||||
listChats: (params?: { query?: string; page?: number; page_size?: number }): Promise<ApiResponse<ChatListData>> =>
|
||||
request.get('/api/ai/chats/', { params, showError: false }),
|
||||
|
||||
createChat: (body?: { title?: string }): Promise<ApiResponse<AIChatDetail>> =>
|
||||
request.post('/api/ai/chats/create/', body || { title: '新对话' }),
|
||||
|
||||
getChatDetail: (chatId: number): Promise<ApiResponse<ChatDetailData>> =>
|
||||
request.get(`/api/ai/chats/${chatId}/`),
|
||||
|
||||
updateChat: (chatId: number, body: { title: string }): Promise<ApiResponse<null>> =>
|
||||
request.put(`/api/ai/chats/${chatId}/update/`, body),
|
||||
|
||||
deleteChats: (chatIds: number[]): Promise<ApiResponse<null>> =>
|
||||
request.post('/api/ai/chats/delete/', { chat_ids: chatIds }),
|
||||
|
||||
sendMessage: (chatId: number, body: { content: string }): Promise<ApiResponse<SendMessageData>> =>
|
||||
request.post(`/api/ai/chats/${chatId}/send/`, body),
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// 统一的菜单项配置接口
|
||||
export interface UserMenuConfig {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
path: string
|
||||
component: string // 组件路径,相对于 @/views/user_pages/
|
||||
children?: UserMenuConfig[]
|
||||
disabled?: boolean
|
||||
meta?: {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
roles?: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单项接口定义(用于前端显示)
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
path?: string
|
||||
children?: MenuItem[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// 统一配置 - 同时用于菜单和路由
|
||||
export const userMenuConfigs: UserMenuConfig[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: '首页',
|
||||
icon: 'DashboardOutlined',
|
||||
path: '/dashboard',
|
||||
component: 'index.vue',
|
||||
meta: { title: '用户首页', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人信息',
|
||||
icon: 'UserOutlined',
|
||||
path: '/user/profile',
|
||||
component: 'Profile.vue',
|
||||
meta: { title: '个人信息', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
label: '文档管理',
|
||||
icon: 'FileTextOutlined',
|
||||
path: '/user/documents',
|
||||
component: 'Documents.vue',
|
||||
meta: { title: '文档管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: '消息中心',
|
||||
icon: 'MessageOutlined',
|
||||
path: '/user/messages',
|
||||
component: 'Messages.vue',
|
||||
meta: { title: '消息中心', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
key: 'system-monitor',
|
||||
label: '系统监控',
|
||||
icon: 'DashboardOutlined',
|
||||
path: '/user/system-monitor',
|
||||
component: 'SystemMonitor.vue',
|
||||
meta: { title: '系统监控', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
key: 'ai-chat',
|
||||
label: 'AI助手',
|
||||
icon: 'MessageOutlined',
|
||||
path: '/user/ai-chat',
|
||||
component: 'AiChat.vue',
|
||||
meta: { title: 'AI助手', requiresAuth: true }
|
||||
},
|
||||
]
|
||||
|
||||
// 显式组件映射 - 避免Vite动态导入限制
|
||||
const explicitComponentMap: Record<string, any> = {
|
||||
'index.vue': defineAsyncComponent(() => import('@/views/user_pages/index.vue')),
|
||||
'Profile.vue': defineAsyncComponent(() => import('@/views/user_pages/Profile.vue')),
|
||||
'Documents.vue': defineAsyncComponent(() => import('@/views/user_pages/Documents.vue')),
|
||||
'Messages.vue': defineAsyncComponent(() => import('@/views/user_pages/Messages.vue')),
|
||||
'SystemMonitor.vue': defineAsyncComponent(() => import('@/views/user_pages/SystemMonitor.vue')),
|
||||
'AiChat.vue': defineAsyncComponent(() => import('@/views/user_pages/AiChat.vue')),
|
||||
}
|
||||
|
||||
// 自动生成菜单项(用于前端显示)
|
||||
export const userMenuItems: MenuItem[] = userMenuConfigs.map(config => ({
|
||||
key: config.key,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
path: config.path,
|
||||
disabled: config.disabled,
|
||||
children: config.children?.map(child => ({
|
||||
key: child.key,
|
||||
label: child.label,
|
||||
icon: child.icon,
|
||||
path: child.path,
|
||||
disabled: child.disabled
|
||||
}))
|
||||
}))
|
||||
|
||||
// 组件映射表 - 用于解决Vite动态导入限制
|
||||
const componentMap: Record<string, () => Promise<any>> = {
|
||||
'index.vue': () => import('@/views/user_pages/index.vue'),
|
||||
'Profile.vue': () => import('@/views/user_pages/Profile.vue'),
|
||||
'Documents.vue': () => import('@/views/user_pages/Documents.vue'),
|
||||
'Messages.vue': () => import('@/views/user_pages/Messages.vue'),
|
||||
'SystemMonitor.vue': () => import('@/views/user_pages/SystemMonitor.vue'),
|
||||
'AiChat.vue': () => import('@/views/user_pages/AiChat.vue'),
|
||||
}
|
||||
|
||||
// 自动生成路由配置
|
||||
export const userRoutes: RouteRecordRaw[] = userMenuConfigs.map(config => {
|
||||
const route: RouteRecordRaw = {
|
||||
path: config.path,
|
||||
name: `User${config.key.charAt(0).toUpperCase() + config.key.slice(1)}`,
|
||||
component: componentMap[config.component] || (() => import('@/views/NotFound.vue')),
|
||||
meta: {
|
||||
title: config.meta?.title || config.label,
|
||||
requiresAuth: config.meta?.requiresAuth ?? true,
|
||||
...config.meta
|
||||
}
|
||||
}
|
||||
|
||||
if (config.children && config.children.length > 0) {
|
||||
route.children = config.children.map(child => ({
|
||||
path: child.path,
|
||||
name: `User${child.key.charAt(0).toUpperCase() + child.key.slice(1)}`,
|
||||
component: componentMap[child.component] || (() => import('@/views/NotFound.vue')),
|
||||
meta: {
|
||||
title: child.meta?.title || child.label,
|
||||
requiresAuth: child.meta?.requiresAuth ?? true,
|
||||
...child.meta
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
|
||||
// 根据菜单项生成路由路径
|
||||
export function getMenuPath(menuKey: string): string {
|
||||
const findPath = (items: MenuItem[], key: string): string | null => {
|
||||
for (const item of items) {
|
||||
if (item.key === key && item.path) return item.path
|
||||
if (item.children) {
|
||||
const childPath = findPath(item.children, key)
|
||||
if (childPath) return childPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findPath(userMenuItems, menuKey) || '/dashboard'
|
||||
}
|
||||
|
||||
// 获取菜单的面包屑路径
|
||||
export function getMenuBreadcrumb(menuKey: string): string[] {
|
||||
const findBreadcrumb = (items: MenuItem[], key: string, path: string[] = []): string[] | null => {
|
||||
for (const item of items) {
|
||||
const currentPath = [...path, item.label]
|
||||
if (item.key === menuKey) return currentPath
|
||||
if (item.children) {
|
||||
const childPath = findBreadcrumb(item.children, key, currentPath)
|
||||
if (childPath) return childPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findBreadcrumb(userMenuItems, menuKey) || ['仪表盘']
|
||||
}
|
||||
|
||||
// 自动生成组件映射(基于配置和显式映射)
|
||||
export const generateComponentMap = () => {
|
||||
const map: Record<string, any> = {}
|
||||
const processConfigs = (configs: UserMenuConfig[]) => {
|
||||
configs.forEach(config => {
|
||||
if (explicitComponentMap[config.component]) {
|
||||
map[config.key] = explicitComponentMap[config.component]
|
||||
} else {
|
||||
map[config.key] = defineAsyncComponent(() => import('@/views/NotFound.vue'))
|
||||
}
|
||||
if (config.children) processConfigs(config.children)
|
||||
})
|
||||
}
|
||||
processConfigs(userMenuConfigs)
|
||||
return map
|
||||
}
|
||||
|
||||
// 导出自动生成的组件映射
|
||||
export const userComponentMap = generateComponentMap()
|
||||
|
||||
// 根据用户权限过滤菜单项
|
||||
export const getFilteredUserMenuItems = (userRoles: string[], userPermissions: string[]): MenuItem[] => {
|
||||
return userMenuConfigs
|
||||
.filter(config => {
|
||||
if (!config.meta?.roles || config.meta.roles.length === 0) return true
|
||||
return config.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
})
|
||||
.map(config => ({
|
||||
key: config.key,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
path: config.path,
|
||||
disabled: config.disabled,
|
||||
children: config.children?.filter(child => {
|
||||
if (!child.meta?.roles || child.meta.roles.length === 0) return true
|
||||
return child.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
}).map(child => ({
|
||||
key: child.key,
|
||||
label: child.label,
|
||||
icon: child.icon,
|
||||
path: child.path,
|
||||
disabled: child.disabled
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
// 检查用户是否有访问特定菜单的权限
|
||||
export const hasUserMenuPermission = (menuKey: string, userRoles: string[]): boolean => {
|
||||
const menuConfig = userMenuConfigs.find(config => config.key === menuKey)
|
||||
if (!menuConfig) return false
|
||||
if (!menuConfig.meta?.roles || menuConfig.meta.roles.length === 0) return true
|
||||
return menuConfig.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
<template>
|
||||
<div class="ai-chat-page">
|
||||
<a-page-header title="AI助手" sub-title="与 AI 进行智能对话">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input-search v-model:value="query" placeholder="搜索会话标题" style="width: 240px" @search="fetchChats" />
|
||||
<a-button @click="fetchChats" :loading="loadingChats">
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="createChat" :loading="creating">
|
||||
<PlusOutlined />
|
||||
新建对话
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<!-- 左侧:会话列表 -->
|
||||
<a-col :xs="24" :md="8" :lg="6">
|
||||
<a-card title="我的对话" bordered>
|
||||
<a-list
|
||||
:data-source="chatList"
|
||||
item-layout="horizontal"
|
||||
:loading="loadingChats"
|
||||
:pagination="{ pageSize: pageSize, total: total, current: page, onChange: onPageChange }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item :class="{ active: item.id === currentChatId }" @click="selectChat(item.id)">
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<div class="chat-title-row">
|
||||
<span class="chat-title">{{ item.title }}</span>
|
||||
<a-space>
|
||||
<a-button size="small" type="text" @click.stop="openRename(item)">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
<a-popconfirm title="确认删除该对话?" @confirm="deleteChat(item.id)">
|
||||
<a-button size="small" danger type="text" @click.stop>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="chat-desc">{{ item.latest_message || '暂无消息' }}</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧:消息区 -->
|
||||
<a-col :xs="24" :md="16" :lg="18">
|
||||
<a-card :title="currentChat?.title || '请选择或新建对话'" bordered class="chat-card">
|
||||
<div class="messages" ref="messagesEl">
|
||||
<template v-if="messages.length">
|
||||
<div v-for="m in messages" :key="m.id" :class="['msg', m.role]">
|
||||
<div class="bubble">
|
||||
<div class="content" v-html="renderContent(m.content)"></div>
|
||||
<div class="time">{{ formatTime(m.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else description="暂无消息" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="composer">
|
||||
<a-textarea v-model:value="input" :rows="3" placeholder="输入你的问题..." :disabled="!currentChatId" />
|
||||
<a-space style="margin-top: 8px;">
|
||||
<a-button type="primary" :disabled="!canSend" :loading="sending" @click="send">
|
||||
<SendOutlined />
|
||||
发送
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 重命名对话 -->
|
||||
<a-modal v-model:open="renameOpen" title="重命名对话" @ok="doRename" :confirm-loading="renaming">
|
||||
<a-input v-model:value="renameTitle" placeholder="请输入新标题" />
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, SendOutlined } from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { aiApi, type AIChatItem, type AIChatDetail, type AIChatMessage } from '@/api/ai'
|
||||
|
||||
// 会话列表状态
|
||||
const chatList = ref<AIChatItem[]>([])
|
||||
const query = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loadingChats = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
// 当前会话与消息
|
||||
const currentChatId = ref<number | null>(null)
|
||||
const currentChat = ref<AIChatDetail | null>(null)
|
||||
const messages = ref<AIChatMessage[]>([])
|
||||
const loadingMessages = ref(false)
|
||||
const sending = ref(false)
|
||||
|
||||
// 重命名
|
||||
const renameOpen = ref(false)
|
||||
const renameTitle = ref('')
|
||||
const renaming = ref(false)
|
||||
let renameTargetId: number | null = null
|
||||
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const canSend = computed(() => !!currentChatId.value && !!input.value.trim())
|
||||
const input = ref('')
|
||||
|
||||
const fetchChats = async () => {
|
||||
loadingChats.value = true
|
||||
try {
|
||||
const res = await aiApi.listChats({ query: query.value || undefined, page: page.value, page_size: pageSize.value })
|
||||
if (res.success) {
|
||||
chatList.value = res.data.chats || []
|
||||
total.value = res.data.total || 0
|
||||
// 保持选择
|
||||
if (!currentChatId.value && chatList.value.length) selectChat(chatList.value[0].id)
|
||||
} else {
|
||||
message.error(res.message || '获取对话列表失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 403) {
|
||||
message.warning('暂无权限访问AI助手,请联系管理员开通权限')
|
||||
} else {
|
||||
message.error(e?.message || '网络错误')
|
||||
}
|
||||
} finally {
|
||||
loadingChats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPageChange = (p: number) => { page.value = p; fetchChats() }
|
||||
|
||||
const selectChat = async (id: number) => {
|
||||
if (currentChatId.value === id && messages.value.length) return
|
||||
currentChatId.value = id
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await aiApi.getChatDetail(id)
|
||||
if (res.success) {
|
||||
currentChat.value = res.data.chat
|
||||
messages.value = res.data.messages || []
|
||||
await nextTick(); scrollToBottom()
|
||||
} else {
|
||||
message.error(res.message || '获取会话详情失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '网络错误')
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createChat = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await aiApi.createChat({ title: '新对话' })
|
||||
if (res.success) {
|
||||
message.success('创建成功')
|
||||
await fetchChats()
|
||||
selectChat(res.data.id)
|
||||
} else {
|
||||
message.error(res.message || '创建失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '网络错误')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRename = (item: AIChatItem) => {
|
||||
renameTargetId = item.id
|
||||
renameTitle.value = item.title
|
||||
renameOpen.value = true
|
||||
}
|
||||
|
||||
const doRename = async () => {
|
||||
if (!renameTargetId || !renameTitle.value.trim()) { message.warning('标题不能为空'); return }
|
||||
renaming.value = true
|
||||
try {
|
||||
const res = await aiApi.updateChat(renameTargetId, { title: renameTitle.value.trim() })
|
||||
if (res.success) { message.success('重命名成功'); renameOpen.value = false; await fetchChats() }
|
||||
else { message.error(res.message || '重命名失败') }
|
||||
} catch (e: any) { message.error(e?.message || '网络错误') }
|
||||
finally { renaming.value = false }
|
||||
}
|
||||
|
||||
const deleteChat = async (id: number) => {
|
||||
try {
|
||||
const res = await aiApi.deleteChats([id])
|
||||
if (res.success) {
|
||||
message.success('删除成功')
|
||||
await fetchChats()
|
||||
if (currentChatId.value === id) { currentChatId.value = null; currentChat.value = null; messages.value = [] }
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (e: any) { message.error(e?.message || '网络错误') }
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend.value || !currentChatId.value) return
|
||||
const content = input.value.trim()
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await aiApi.sendMessage(currentChatId.value, { content })
|
||||
if (res.success) {
|
||||
messages.value.push(res.data.user_message, res.data.ai_message)
|
||||
input.value = ''
|
||||
await nextTick(); scrollToBottom(); fetchChats() // 更新列表预览
|
||||
} else {
|
||||
message.error(res.message || '发送失败')
|
||||
}
|
||||
} catch (e: any) { message.error(e?.message || '网络错误') }
|
||||
finally { sending.value = false }
|
||||
}
|
||||
|
||||
const formatTime = (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||||
const renderContent = (c: string) => c.replace(/\n/g, '<br/>')
|
||||
const scrollToBottom = () => { const el = messagesEl.value; if (el) el.scrollTop = el.scrollHeight }
|
||||
|
||||
onMounted(() => { fetchChats() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-chat-page { padding: 16px; }
|
||||
.chat-title-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.chat-desc { color: #666; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.messages { min-height: 420px; max-height: 60vh; overflow-y: auto; padding: 8px; background: #fafafa; border-radius: 8px; }
|
||||
.msg { display: flex; margin-bottom: 12px; }
|
||||
.msg .bubble { max-width: 80%; padding: 10px 12px; border-radius: 8px; position: relative; }
|
||||
.msg .time { margin-top: 6px; font-size: 12px; color: #999; }
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .bubble { background: #e6f7ff; }
|
||||
.msg.assistant { justify-content: flex-start; }
|
||||
.msg.assistant .bubble { background: #f6ffed; }
|
||||
.composer { margin-top: 8px; }
|
||||
.chat-card :deep(.ant-card-head) { background: #fff; }
|
||||
.active { background: #f0f7ff; }
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,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,
|
||||
|
||||
@@ -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} - 管理系统`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
149
hertz_server_diango_ui/src/views/ModuleSetup.vue
Normal file
149
hertz_server_diango_ui/src/views/ModuleSetup.vue
Normal 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
|
||||
1404
hertz_server_diango_ui/src/views/admin_page/DatasetManagement.vue
Normal file
1404
hertz_server_diango_ui/src/views/admin_page/DatasetManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1305
hertz_server_diango_ui/src/views/admin_page/YoloTrainManagement.vue
Normal file
1305
hertz_server_diango_ui/src/views/admin_page/YoloTrainManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<template>这是我user_pages</template>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
2499
hertz_server_diango_ui/src/views/user_pages/KbCenter.vue
Normal file
2499
hertz_server_diango_ui/src/views/user_pages/KbCenter.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 模型失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 自动检测位置并查询天气(延迟执行,避免影响页面加载)
|
||||
|
||||
Reference in New Issue
Block a user