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

11
.env
View File

@@ -3,13 +3,14 @@ SECRET_KEY=django-insecure-0a1bx*8!97l^4z#ml#ufn_*9ut*)zlso$*k-g^h&(2=p@^51md
DEBUG=True
#ALLOWED_HOSTS=localhost,127.0.0.1,django-host,192.168.1.22
ALLOWED_HOSTS=*
# Database Configuration
# 切换数据源支持sqlite/mysql
DB_ENGINE=sqlite
USE_REDIS_AS_DB=True
# MySQL Configuration (when USE_REDIS_AS_DB=False)
DB_NAME=hertz_server
DB_USER=root
DB_PASSWORD=root
DB_PASSWORD=123456
DB_HOST=localhost
DB_PORT=3306
@@ -27,9 +28,9 @@ EMAIL_HOST=smtp.qq.com
EMAIL_PORT=465
EMAIL_USE_SSL=True
EMAIL_USE_TLS=False
EMAIL_HOST_USER=your_email@example.com
EMAIL_HOST_PASSWORD=your_email_password_or_app_key
DEFAULT_FROM_EMAIL=your_email@example.com
EMAIL_HOST_USER=your_email@qq.com
EMAIL_HOST_PASSWORD=your_email_password
DEFAULT_FROM_EMAIL=your_email@qq.com
# 注册邮箱验证码开关0=关闭1=开启)
REGISTER_EMAIL_VERIFICATION=0

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@ __pycache__/
# C extensions
*.so
# python envs
venv/
# Distribution / packaging
.Python
build/

Binary file not shown.

View File

@@ -58,7 +58,7 @@ def generate_crud_menu(args):
print("请重启服务器以同步菜单到数据库")
def main():
def menu_generator_main():
parser = argparse.ArgumentParser(description='菜单生成器')
subparsers = parser.add_subparsers(dest='command', help='可用命令')
@@ -81,4 +81,4 @@ def main():
if __name__ == "__main__":
main()
menu_generator_main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 496 B

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@
<DatabaseOutlined class="header-icon" />
</div>
<div class="header-text">
<h1 class="page-title">知识库管理</h1>
<p class="page-description">集中管理内部知识文档支持分类标签和搜索</p>
<h1 class="page-title">文章管理</h1>
<p class="page-description">集中管理文章内容支持分类标签和搜索</p>
</div>
</div>
</div>
@@ -60,9 +60,9 @@
<!-- 内容区分类树 + 文档列表 -->
<div class="content-container">
<a-row :gutter="16">
<a-row :gutter="[8, 16]">
<!-- 左侧分类树 -->
<a-col :xs="24" :md="6">
<a-col :xs="24" :md="5">
<div class="category-container">
<a-card title="分类" size="small" :loading="false" :bordered="false">
<a-tree
@@ -77,7 +77,7 @@
</a-col>
<!-- 右侧文档列表 -->
<a-col :xs="24" :md="18">
<a-col :xs="24" :md="19">
<div class="table-container">
<a-card title="文档列表" size="small" :bordered="false">
<a-table
@@ -88,32 +88,42 @@
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status_display'">
<a-tag :color="statusColor(record.status)">
{{ record.status_display }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="link" size="small" @click="handlePublish(record)" :disabled="record.status === 'published'">
发布
</a-button>
<a-button type="link" size="small" @click="handleArchive(record)" :disabled="record.status === 'archived'">
归档
</a-button>
<a-popconfirm title="确认删除该文档?" @confirm="() => handleDelete(record)">
<a-button danger type="link" size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status_display'">
<a-tag :color="statusColor(record.status)">
{{ record.status_display }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space wrap size="small">
<a-button type="link" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button
type="link"
size="small"
@click="handlePublish(record)"
:disabled="record.status === 'published'"
>
发布
</a-button>
<a-button
type="link"
size="small"
@click="handleArchive(record)"
:disabled="record.status === 'archived'"
>
归档
</a-button>
<a-popconfirm title="确认删除该文档?" @confirm="() => handleDelete(record)">
<a-button danger type="link" size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
@@ -144,7 +154,13 @@
</a-drawer>
<!-- 新建/编辑文档弹窗 -->
<a-modal v-model:visible="editVisible" :title="editMode === 'create' ? '新建文档' : '编辑文档'" @ok="handleSave" :confirm-loading="saving" width="720px">
<a-modal
v-model:visible="editVisible"
:title="editMode === 'create' ? '新建文档' : '编辑文档'"
@ok="handleSave"
:confirm-loading="saving"
width="720px"
>
<a-form :model="editForm" layout="vertical">
<a-form-item label="标题" required>
<a-input v-model:value="editForm.title" placeholder="请输入文档标题" />
@@ -162,93 +178,112 @@
</a-radio-group>
</a-form-item>
<a-form-item label="内容">
<a-textarea v-model:value="editForm.content" rows="8" placeholder="这里是内容编辑区域(可接入富文本编辑器)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 分类管理弹窗 -->
<a-modal v-model:visible="categoryVisible" title="分类管理" width="800px" :footer="null">
<div class="category-toolbar">
<a-space wrap>
<a-button type="primary" @click="handleCategoryAdd">
<template #icon><PlusOutlined /></template>
新建分类
</a-button>
<a-input-search
v-model:value="categorySearchName"
placeholder="按名称搜索分类"
style="width: 280px"
@search="fetchCategoryListImmediate"
allow-clear
:loading="categoryLoading"
>
<template #enterButton>
<a-button type="primary">搜索</a-button>
</template>
</a-input-search>
</a-space>
</div>
<a-table
:columns="categoryColumns"
:data-source="categoryList"
:loading="categoryLoading"
:pagination="categoryPagination"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'children_count'">
{{ record.children_count ?? 0 }}
</template>
<template v-else-if="column.key === 'articles_count'">
{{ record.articles_count ?? 0 }}
</template>
<template v-if="column.key === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="handleCategoryToggleActive(record)">
{{ record.is_active ? '停用' : '启用' }}
</a-button>
<a-button type="link" size="small" @click="handleCategoryEdit(record)">
编辑
</a-button>
<a-popconfirm title="确认删除该分类?" @confirm="() => handleCategoryDelete(record)">
<a-button danger type="link" size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 分类编辑弹窗 -->
<a-modal v-model:visible="categoryEditVisible" :title="categoryEditMode === 'create' ? '新建分类' : '编辑分类'" @ok="handleCategorySave" :confirm-loading="categorySaving" width="640px">
<a-form :model="categoryForm" layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="categoryForm.name" placeholder="请输入分类名称" />
</a-form-item>
<a-form-item label="父级分类">
<a-select v-model:value="categoryForm.parent" :options="categoryOptions" placeholder="请选择父级分类(可选)" allow-clear />
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="categoryForm.sort_order" :min="0" :max="9999" style="width: 160px" />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="categoryForm.is_active" checked-children="启用" un-checked-children="停用" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="categoryForm.description" rows="4" placeholder="请输入分类描述(可选)" />
<a-textarea
v-model:value="editForm.content"
rows="8"
placeholder="这里是内容编辑区域(可接入富文本编辑器)"
/>
</a-form-item>
</a-form>
</a-modal>
</a-modal>
</div>
<!-- 分类管理弹窗 -->
<a-modal v-model:visible="categoryVisible" title="分类管理" width="800px" :footer="null">
<div class="category-toolbar">
<a-space wrap>
<a-button type="primary" @click="handleCategoryAdd">
<template #icon><PlusOutlined /></template>
新建分类
</a-button>
<a-input-search
v-model:value="categorySearchName"
placeholder="按名称搜索分类"
style="width: 280px"
@search="fetchCategoryListImmediate"
allow-clear
:loading="categoryLoading"
>
<template #enterButton>
<a-button type="primary">搜索</a-button>
</template>
</a-input-search>
</a-space>
</div>
<a-table
:columns="categoryColumns"
:data-source="categoryList"
:loading="categoryLoading"
:pagination="categoryPagination"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'children_count'">
{{ record.children_count ?? 0 }}
</template>
<template v-else-if="column.key === 'articles_count'">
{{ record.articles_count ?? 0 }}
</template>
<template v-if="column.key === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? '启用' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="handleCategoryToggleActive(record)">
{{ record.is_active ? '停用' : '启用' }}
</a-button>
<a-button type="link" size="small" @click="handleCategoryEdit(record)">
编辑
</a-button>
<a-popconfirm title="确认删除该分类?" @confirm="() => handleCategoryDelete(record)">
<a-button danger type="link" size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- 分类编辑弹窗 -->
<a-modal
v-model:visible="categoryEditVisible"
:title="categoryEditMode === 'create' ? '新建分类' : '编辑分类'"
@ok="handleCategorySave"
:confirm-loading="categorySaving"
width="640px"
>
<a-form :model="categoryForm" layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="categoryForm.name" placeholder="请输入分类名称" />
</a-form-item>
<a-form-item label="父级分类">
<a-select
v-model:value="categoryForm.parent"
:options="categoryOptions"
placeholder="请选择父级分类(可选)"
allow-clear
/>
</a-form-item>
<a-form-item label="排序">
<a-input-number
v-model:value="categoryForm.sort_order"
:min="0"
:max="9999"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="categoryForm.is_active" checked-children="启用" un-checked-children="停用" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="categoryForm.description" rows="4" placeholder="请输入分类描述(可选)" />
</a-form-item>
</a-form>
</a-modal>
</a-modal>
</div>
</template>
<script setup lang="ts">
@@ -265,9 +300,6 @@ import {
} from '@ant-design/icons-vue'
import { knowledgeApi, type KnowledgeArticleListItem, type KnowledgeArticleDetail, type KnowledgeCategory } from '@/api/knowledge'
//
const loading = ref(false)
@@ -275,7 +307,7 @@ const loading = ref(false)
const searchText = ref('')
//
const pagination = ref({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, pageSizeOptions: ['10','20','50'] })
const pagination = ref({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, pageSizeOptions: ['10', '20', '50'] })
//
const categoryTree = ref<any[]>([{ title: '全部', key: 'all' }])
@@ -286,8 +318,18 @@ const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '分类', dataIndex: 'category_name', key: 'category_name', ellipsis: true },
{ title: '状态', dataIndex: 'status_display', key: 'status_display' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '发布时间', dataIndex: 'published_at', key: 'published_at' },
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
customRender: ({ text }: { text: string }) => formatDateOnly(text)
},
{
title: '发布时间',
dataIndex: 'published_at',
key: 'published_at',
customRender: ({ text }: { text: string }) => (text ? formatDateOnly(text) : '-')
},
{ title: '操作', key: 'actions' }
]
@@ -316,14 +358,15 @@ const categoryOptions = computed(() => {
//
const statusColor = (s: 'draft' | 'published' | 'archived') => {
switch (s) {
case 'published': return 'green'
case 'archived': return 'orange'
default: return 'blue'
case 'published':
return 'green'
case 'archived':
return 'orange'
default:
return 'blue'
}
}
//
const detailOpen = ref(false)
const currentDoc = ref<KnowledgeArticleDetail | null>(null)
@@ -332,7 +375,15 @@ const currentDoc = ref<KnowledgeArticleDetail | null>(null)
const editVisible = ref(false)
const editMode = ref<'create' | 'update'>('create')
const saving = ref(false)
const editForm = ref<any>({ id: 0, title: '', category: undefined as number | undefined, tagsArray: [] as string[], status: 'draft' as 'draft' | 'published', content: '', summary: '' })
const editForm = ref<any>({
id: 0,
title: '',
category: undefined as number | undefined,
tagsArray: [] as string[],
status: 'draft' as 'draft' | 'published',
content: '',
summary: ''
})
onMounted(() => {
fetchCategories()
@@ -341,7 +392,6 @@ onMounted(() => {
const fetchCategories = async () => {
try {
const res = await knowledgeApi.getCategoryTree()
const data = (res.data || []) as KnowledgeCategory[]
const mapNode = (n: KnowledgeCategory): any => ({
@@ -364,7 +414,7 @@ const fetchArticles = async () => {
const params: any = {
page: needsClientFilter ? 1 : pagination.value.current,
page_size: needsClientFilter ? 50 : pagination.value.pageSize, //
page_size: needsClientFilter ? 50 : pagination.value.pageSize //
}
//
@@ -389,10 +439,12 @@ const fetchArticles = async () => {
const tags = (article.tags_list || []).join(' ').toLowerCase()
const summary = (article.summary || '').toLowerCase()
const categoryName = (article.category_name || '').toLowerCase()
return title.includes(keywordLower) ||
tags.includes(keywordLower) ||
summary.includes(keywordLower) ||
categoryName.includes(keywordLower)
return (
title.includes(keywordLower) ||
tags.includes(keywordLower) ||
summary.includes(keywordLower) ||
categoryName.includes(keywordLower)
)
})
//
@@ -451,9 +503,16 @@ const handleView = async (record: KnowledgeArticleListItem) => {
}
const handleAdd = () => {
editMode.value = 'create'
editForm.value = { id: 0, title: '', category: undefined, tagsArray: [], status: 'draft', content: '', summary: '' }
editForm.value = {
id: 0,
title: '',
category: undefined,
tagsArray: [],
status: 'draft',
content: '',
summary: ''
}
editVisible.value = true
}
@@ -526,7 +585,7 @@ const handleSave = async () => {
category: Number(categoryVal),
status: editForm.value.status || 'draft',
tags: tags || undefined,
sort_order: 0,
sort_order: 0
}
if (editMode.value === 'create') {
@@ -576,7 +635,6 @@ const handleArchive = async (record: KnowledgeArticleListItem) => {
}
const openCategoryManager = () => {
categoryVisible.value = true
fetchCategoryList()
}
@@ -607,10 +665,12 @@ pagination.value = {
const tags = (article.tags_list || []).join(' ').toLowerCase()
const summary = (article.summary || '').toLowerCase()
const categoryName = (article.category_name || '').toLowerCase()
return title.includes(keyword) ||
tags.includes(keyword) ||
summary.includes(keyword) ||
categoryName.includes(keyword)
return (
title.includes(keyword) ||
tags.includes(keyword) ||
summary.includes(keyword) ||
categoryName.includes(keyword)
)
})
//
@@ -640,10 +700,12 @@ pagination.value = {
const tags = (article.tags_list || []).join(' ').toLowerCase()
const summary = (article.summary || '').toLowerCase()
const categoryName = (article.category_name || '').toLowerCase()
return title.includes(keyword) ||
tags.includes(keyword) ||
summary.includes(keyword) ||
categoryName.includes(keyword)
return (
title.includes(keyword) ||
tags.includes(keyword) ||
summary.includes(keyword) ||
categoryName.includes(keyword)
)
})
//
@@ -665,7 +727,13 @@ const categoryVisible = ref(false)
const categoryLoading = ref(false)
const categoryList = ref<KnowledgeCategory[]>([])
const categorySearchName = ref('')
const categoryPagination = ref({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, pageSizeOptions: ['10','20','50'] })
const categoryPagination = ref({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50']
})
const categoryColumns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '父级', dataIndex: 'parent_name', key: 'parent_name' },
@@ -673,20 +741,27 @@ const categoryColumns = [
{ title: '子分类数', dataIndex: 'children_count', key: 'children_count' },
{ title: '文章数', dataIndex: 'articles_count', key: 'articles_count' },
{ title: '状态', key: 'is_active' },
{ title: '操作', key: 'actions' },
{ title: '操作', key: 'actions' }
]
const categoryEditVisible = ref(false)
const categoryEditMode = ref<'create' | 'update'>('create')
const categorySaving = ref(false)
const categoryForm = ref<Partial<KnowledgeCategory>>({ id: 0, name: '', description: '', parent: undefined, sort_order: 0, is_active: true })
const categoryForm = ref<Partial<KnowledgeCategory>>({
id: 0,
name: '',
description: '',
parent: undefined,
sort_order: 0,
is_active: true
})
const fetchCategoryList = async () => {
categoryLoading.value = true
try {
const params: any = {
page: categoryPagination.value.current,
page_size: categoryPagination.value.pageSize,
page_size: categoryPagination.value.pageSize
}
const kw = categorySearchName.value.trim()
if (kw) params.name = kw
@@ -721,16 +796,27 @@ categoryPagination.value = {
}
const handleCategoryAdd = () => {
categoryEditMode.value = 'create'
categoryForm.value = { name: '', description: '', parent: undefined, sort_order: 0, is_active: true }
categoryForm.value = {
name: '',
description: '',
parent: undefined,
sort_order: 0,
is_active: true
}
categoryEditVisible.value = true
}
const handleCategoryEdit = (record: KnowledgeCategory) => {
categoryEditMode.value = 'update'
categoryForm.value = { id: record.id, name: record.name, description: record.description, parent: record.parent ?? undefined, sort_order: record.sort_order, is_active: record.is_active }
categoryForm.value = {
id: record.id,
name: record.name,
description: record.description,
parent: record.parent ?? undefined,
sort_order: record.sort_order,
is_active: record.is_active
}
categoryEditVisible.value = true
}
@@ -742,7 +828,7 @@ const handleCategorySave = async () => {
description: categoryForm.value.description || undefined,
parent: categoryForm.value.parent ?? undefined,
sort_order: categoryForm.value.sort_order ?? 0,
is_active: categoryForm.value.is_active ?? true,
is_active: categoryForm.value.is_active ?? true
}
if (categoryEditMode.value === 'create') {
@@ -788,6 +874,13 @@ const handleCategoryToggleActive = async (record: KnowledgeCategory) => {
console.error(e)
}
}
// YYYY-MM-DD
const formatDateOnly = (val?: string): string => {
if (!val) return '-'
const str = String(val)
return str.length >= 10 ? str.slice(0, 10) : str
}
</script>
<style scoped lang="scss">
@@ -921,6 +1014,10 @@ const handleCategoryToggleActive = async (record: KnowledgeCategory) => {
}
.search-section {
display: flex;
align-items: center;
gap: 12px;
:deep(.ant-input-search) {
.ant-input {
border-radius: 8px;
@@ -1012,7 +1109,7 @@ const handleCategoryToggleActive = async (record: KnowledgeCategory) => {
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;
}
@@ -1025,7 +1122,7 @@ const handleCategoryToggleActive = async (record: KnowledgeCategory) => {
}
> td {
padding: 16px;
padding: 6px 8px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
color: #1d1d1f;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,24 @@
<div class="user-info-section">
<div class="user-info-card">
<div class="user-avatar-section">
<a-avatar :size="100" class="avatar">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<a-upload
:show-upload-list="false"
:before-upload="handleAvatarBeforeUpload"
>
<a-avatar
:size="100"
class="avatar"
:src="userForm.avatar || userStore.userInfo?.avatar"
>
<template #icon>
<UserOutlined />
</template>
</a-avatar>
</a-upload>
<div class="avatar-info">
<div class="username">{{ userForm.username || '用户' }}</div>
<div class="user-role">普通用户</div>
<div class="avatar-hint">点击头像更换图片</div>
</div>
</div>
</div>
@@ -194,19 +204,24 @@ import {
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,9 +242,16 @@ 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)
@@ -255,20 +277,69 @@ const resetForm = async () => {
}
}
const handleAvatarBeforeUpload = async (file: File) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
message.error('只能上传图片文件作为头像')
return false
}
if (!isLt2M) {
message.error('头像图片大小不能超过 2MB')
return false
}
avatarUploading.value = true
try {
const res: any = await userApi.uploadAvatar(file)
// 兼容两种返回结构:
// 1) 标准 { success, message, data: { avatar, ... } }
// 2) 直接返回用户对象 { avatar, ... }
const updatedUser = res?.data ?? res
// 后端返回字段为 data.avatar_url这里兼容 avatar 和 avatar_url
const newAvatar = updatedUser?.avatar || updatedUser?.avatar_url
if (newAvatar) {
// 更新当前页面表单
userForm.value.avatar = newAvatar
// 更新全局用户 store导航栏头像立即刷新
if (userStore.userInfo) {
userStore.userInfo.avatar = newAvatar
localStorage.setItem('userInfo', JSON.stringify(userStore.userInfo))
}
const ok = typeof res?.success === 'boolean' ? res.success : true
if (ok) {
message.success(res?.message || '头像上传成功')
} else {
message.error(res?.message || '头像上传失败')
}
} else {
message.error(res?.message || '头像上传失败:未返回头像地址')
}
} catch (error: any) {
message.error(error?.message || '头像上传失败,请稍后重试')
} finally {
avatarUploading.value = false
}
// 阻止 a-upload 自己提交,由我们手动处理
return false
}
onMounted(() => {
fetchUserInfo()
})
</script>
<style scoped lang="scss">
@import '@/styles/variables';
.profile-page {
padding: 24px;
background: var(--theme-page-bg, #f5f5f5);
min-height: 100vh;
// 页面头部 - 与知识库中心一致
.page-header {
margin-bottom: 24px;
text-align: center;
@@ -295,7 +366,6 @@ onMounted(() => {
}
}
// 用户信息卡片
.user-info-section {
max-width: 1200px;
margin: 0 auto 24px;
@@ -315,6 +385,7 @@ onMounted(() => {
.avatar {
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
border: 3px solid var(--theme-card-border, #e5e7eb);
cursor: pointer;
}
.avatar-info {
@@ -330,12 +401,17 @@ onMounted(() => {
font-size: 14px;
font-weight: 500;
}
.avatar-hint {
margin-top: 4px;
font-size: 12px;
color: var(--theme-text-secondary, #94a3b8);
}
}
}
}
}
// 资料表单容器
.profile-container {
max-width: 1200px;
margin: 0 auto;
@@ -347,7 +423,6 @@ onMounted(() => {
border: 1px solid var(--theme-card-border, #e5e7eb);
display: flex;
flex-direction: column;
height: 100%;
.panel-header {
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
@@ -415,66 +490,6 @@ onMounted(() => {
.form-item {
margin-bottom: 20px;
:deep(.ant-form-item-label) {
label {
font-weight: 600;
color: var(--theme-text-primary, #1e293b);
font-size: 14px;
}
}
.form-input {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:focus,
&:hover {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&:disabled {
background: var(--theme-card-hover-bg, #f9fafb);
color: var(--theme-text-secondary, #6b7280);
border-color: var(--theme-card-border, #e5e7eb);
}
}
.form-select {
:deep(.ant-select-selector) {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
}
}
:deep(.ant-select-focused .ant-select-selector) {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.form-date-picker {
:deep(.ant-picker) {
border-radius: 8px;
border: 1px solid var(--theme-card-border, #e5e7eb);
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
}
&.ant-picker-focused {
border-color: var(--theme-primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
}
}
}
}
@@ -487,81 +502,13 @@ onMounted(() => {
padding-top: 24px;
border-top: 1px solid var(--theme-card-border, #e5e7eb);
.submit-btn {
border-radius: 8px;
height: 44px;
padding: 0 32px;
font-weight: 600;
font-size: 15px;
transition: all 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
}
.submit-btn,
.reset-btn {
border-radius: 8px;
height: 44px;
padding: 0 32px;
font-weight: 500;
font-size: 15px;
transition: all 0.2s;
&:hover {
border-color: var(--theme-primary, #3b82f6);
color: var(--theme-primary, #3b82f6);
}
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.profile-page {
padding: 16px;
.user-info-section {
.user-info-card {
padding: 24px;
.user-avatar-section {
flex-direction: column;
text-align: center;
gap: 16px;
}
}
}
.profile-container {
.profile-wrapper {
.panel-content {
padding: 20px;
.profile-form {
.form-grid {
gap: 24px;
.form-section {
.form-item {
margin-bottom: 16px;
}
}
}
.form-actions {
flex-direction: column;
align-items: stretch;
.submit-btn,
.reset-btn {
width: 100%;
}
}
}
}

View File

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

View File

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

View File

@@ -8,27 +8,33 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from django.conf import settings
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hertz_server_django.settings')
# Import Django first to ensure proper initialization
from django.core.asgi import get_asgi_application
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()
# Import other modules AFTER Django setup
from django.conf import settings
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
# Import websocket routing AFTER Django setup to avoid AppRegistryNotReady
from hertz_demo import routing as demo_routing
from hertz_studio_django_yolo import routing as yolo_routing
# Merge websocket routes
websocket_urlpatterns = (
demo_routing.websocket_urlpatterns +
yolo_routing.websocket_urlpatterns
)
if 'hertz_studio_django_yolo' in settings.INSTALLED_APPS:
from hertz_studio_django_yolo import routing as yolo_routing
websocket_urlpatterns = (
demo_routing.websocket_urlpatterns +
yolo_routing.websocket_urlpatterns
)
else:
websocket_urlpatterns = demo_routing.websocket_urlpatterns
# 在开发环境下放宽Origin校验便于第三方客户端如 Apifox、wscat调试
websocket_app = AuthMiddlewareStack(
@@ -47,3 +53,4 @@ else:
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(websocket_app),
})

View File

@@ -62,8 +62,12 @@ DEBUG = config('DEBUG', default=True, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1', cast=lambda v: [s.strip() for s in v.split(',')])
# Database switch configuration
# Database engine configuration (sqlite/mysql) with backward compatibility
# Prefer `DB_ENGINE` env var; fallback to legacy `USE_REDIS_AS_DB`
DB_ENGINE = config('DB_ENGINE', default=None)
USE_REDIS_AS_DB = config('USE_REDIS_AS_DB', default=True, cast=bool)
if DB_ENGINE is None:
DB_ENGINE = 'sqlite' if USE_REDIS_AS_DB else 'mysql'
# Application definition
@@ -82,13 +86,19 @@ INSTALLED_APPS = [
'drf_spectacular',
# 必备注册的app不要删
'hertz_demo', # 初始化演示模块
'hertz_demo', # 初始化演示模块
'hertz_studio_django_captcha', # 验证码模块
'hertz_studio_django_auth', # 权限模块
'hertz_studio_django_system_monitor', # 系统监测模块
'hertz_studio_django_log', # 日志管理模块
'hertz_studio_django_notice', # 通知模块
# ======在下面导入你需要的app======
'hertz_studio_django_ai', #ai聊天模块
'hertz_studio_django_kb', # 知识库 ai和kb库是相互绑定的
'hertz_studio_django_wiki', # 文章模块
'hertz_studio_django_yolo', # YOLO目标检测模块
'hertz_studio_django_yolo_train', # Yolo训练模块
]
@@ -128,21 +138,17 @@ WSGI_APPLICATION = 'hertz_server_django.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
if USE_REDIS_AS_DB:
# Redis as primary database (for caching and session storage)
if DB_ENGINE == 'sqlite':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data/db.sqlite3',
}
}
# Use Redis for sessions
# Use Redis-backed sessions when on SQLite (optional, keeps prior behavior)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
else:
# MySQL database configuration
elif DB_ENGINE == 'mysql':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@@ -156,6 +162,14 @@ else:
},
}
}
else:
# Fallback to SQLite for unexpected values
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data/db.sqlite3',
}
}
# Redis
CACHES = {

View File

@@ -45,8 +45,25 @@ urlpatterns = [
# Hertz Log routes
path('api/log/', include('hertz_studio_django_log.urls')),
# Hertz Notice routes
path('api/notice/', include('hertz_studio_django_notice.urls')),
# ===========在下面添加你需要的路由===========
# Hertz AI routes
path('api/ai/', include('hertz_studio_django_ai.urls')),
# Hertz Knowledge Base routes
path('api/kb/', include('hertz_studio_django_kb.urls')),
# Hertz Wiki routes
path('api/wiki/', include('hertz_studio_django_wiki.urls')),
# Hertz YOLO routes
path('api/yolo/', include('hertz_studio_django_yolo.urls')),
# YOLO 训练管理
path('api/yolo/train/', include('hertz_studio_django_yolo_train.urls')),
]

View File

@@ -0,0 +1,146 @@
"""
将数据库中的绝对路径转换为相对路径的脚本
执行方式: python convert_paths_to_relative.py
"""
import os
import sys
import django
# 设置Django环境
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hertz_server_django.settings')
django.setup()
from django.conf import settings
from hertz_studio_django_yolo.models import YoloModel, Dataset
def convert_to_relative_path(absolute_path):
"""将绝对路径转换为相对于MEDIA_ROOT的相对路径"""
if not absolute_path:
return None
# 如果已经是相对路径,直接返回
if not os.path.isabs(absolute_path):
return absolute_path
try:
# 计算相对路径
relative_path = os.path.relpath(absolute_path, settings.MEDIA_ROOT)
return relative_path
except ValueError:
# 如果路径不在MEDIA_ROOT下,返回原路径
print(f"警告: 路径 {absolute_path} 不在 MEDIA_ROOT 下")
return absolute_path
def convert_yolo_models():
"""转换YoloModel中的路径"""
print("开始转换 YoloModel 表中的路径...")
models = YoloModel.objects.all()
updated_count = 0
for model in models:
updated = False
# 转换 model_folder_path
if model.model_folder_path and os.path.isabs(model.model_folder_path):
old_path = model.model_folder_path
model.model_folder_path = convert_to_relative_path(old_path)
print(f" 模型 {model.name}: model_folder_path")
print(f" 原路径: {old_path}")
print(f" 新路径: {model.model_folder_path}")
updated = True
# 转换 best_model_path
if model.best_model_path and os.path.isabs(model.best_model_path):
old_path = model.best_model_path
model.best_model_path = convert_to_relative_path(old_path)
print(f" 模型 {model.name}: best_model_path")
print(f" 原路径: {old_path}")
print(f" 新路径: {model.best_model_path}")
updated = True
# 转换 last_model_path
if model.last_model_path and os.path.isabs(model.last_model_path):
old_path = model.last_model_path
model.last_model_path = convert_to_relative_path(old_path)
print(f" 模型 {model.name}: last_model_path")
print(f" 原路径: {old_path}")
print(f" 新路径: {model.last_model_path}")
updated = True
if updated:
model.save()
updated_count += 1
print(f"YoloModel 转换完成! 更新了 {updated_count} 个模型记录\n")
def convert_datasets():
"""转换Dataset中的路径"""
print("开始转换 Dataset 表中的路径...")
datasets = Dataset.objects.all()
updated_count = 0
for dataset in datasets:
updated = False
# 转换 root_folder_path
if dataset.root_folder_path and os.path.isabs(dataset.root_folder_path):
old_path = dataset.root_folder_path
dataset.root_folder_path = convert_to_relative_path(old_path)
print(f" 数据集 {dataset.name}: root_folder_path")
print(f" 原路径: {old_path}")
print(f" 新路径: {dataset.root_folder_path}")
updated = True
# 转换 data_yaml_path
if dataset.data_yaml_path and os.path.isabs(dataset.data_yaml_path):
old_path = dataset.data_yaml_path
dataset.data_yaml_path = convert_to_relative_path(old_path)
print(f" 数据集 {dataset.name}: data_yaml_path")
print(f" 原路径: {old_path}")
print(f" 新路径: {dataset.data_yaml_path}")
updated = True
if updated:
dataset.save()
updated_count += 1
print(f"Dataset 转换完成! 更新了 {updated_count} 个数据集记录\n")
def main():
print("=" * 80)
print("路径转换工具 - 将绝对路径转换为相对路径")
print("=" * 80)
print(f"MEDIA_ROOT: {settings.MEDIA_ROOT}\n")
# 确认执行
confirm = input("是否开始转换? (输入 yes 确认): ")
if confirm.lower() != 'yes':
print("操作已取消")
return
try:
# 转换YoloModel
convert_yolo_models()
# 转换Dataset
convert_datasets()
print("=" * 80)
print("所有路径转换完成!")
print("=" * 80)
except Exception as e:
print(f"错误: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()

View File

@@ -28,10 +28,11 @@ requests>=2.32.3
hertz-studio-django-ai
hertz-studio-django-auth
hertz-studio-django-captcha
hertz-studio-django-codegen
hertz-studio-django-kb
hertz-studio-django-log
hertz-studio-django-notice
hertz-studio-django-system-monitor
hertz-studio-django-wiki
hertz-studio-django-yolo
hertz-studio-django-codegen
hertz-studio-django-yolo-train

View File

@@ -294,6 +294,32 @@ def init_superuser():
print(f"超级管理员账号创建成功: {superuser.username}")
return superuser
def init_demo_user():
from hertz_studio_django_auth.models import HertzUser, HertzUserRole, HertzRole
print("正在初始化普通用户账号...")
if HertzUser.objects.filter(username='demo').exists():
print("普通用户账号已存在,跳过创建")
user = HertzUser.objects.get(username='demo')
else:
user = HertzUser.objects.create_user(
username='demo',
email='demo@hertz.com',
password='123456',
real_name='普通用户',
status=1
)
print(f"普通用户账号创建成功: {user.username}")
try:
role = HertzRole.objects.get(role_id=3)
user_role, created = HertzUserRole.objects.get_or_create(user=user, role=role)
if created:
print(f"为用户 {user.username} 分配角色ID: {role.role_id}")
else:
print(f"用户 {user.username} 已拥有角色ID: {role.role_id}")
except HertzRole.DoesNotExist:
print("角色ID=3不存在跳过分配")
return user
def init_departments():
"""
@@ -607,41 +633,6 @@ def assign_user_roles(superuser, roles):
print(f"用户 {superuser.username} 已拥有角色: {super_admin_role.role_name}")
def init_yolo_data():
"""
初始化YOLO模块数据
"""
print("初始化YOLO模块数据...")
try:
from hertz_studio_django_yolo.models import YoloModelConfig
# 检查是否已存在默认模型配置
if YoloModelConfig.objects.filter(is_default=True).exists():
print("✅ YOLO默认模型配置已存在")
return
# 创建默认YOLO模型配置
default_model = YoloModelConfig.objects.create(
model_name="YOLOv12古建筑检测模型",
model_path="static/models/yolov12/weights/best.pt",
model_version="1.0.0",
description="用于古建筑识别的YOLOv12模型",
confidence_threshold=0.5,
iou_threshold=0.45,
max_detections=1000,
input_size="640",
class_labels='["古塔", "古桥", "古建筑", "传统建筑", "文物建筑"]',
is_active=True,
is_default=True
)
print(f"✅ 创建默认YOLO模型配置: {default_model.model_name}")
except Exception as e:
print(f"❌ 初始化YOLO模块数据失败: {e}")
def sync_generated_menus():
"""
同步代码生成器生成的菜单权限
@@ -883,7 +874,6 @@ def init_database():
try:
with transaction.atomic():
# 1. 初始化超级管理员
superuser = init_superuser()
# 2. 初始化部门
@@ -904,11 +894,11 @@ def init_database():
# 7. 为生成的菜单分配权限
assign_generated_menu_permissions(generated_menus)
# 8. 分配用户角色
assign_user_roles(superuser, roles)
demo_user = init_demo_user()
# 9. 初始化YOLO模块数据
init_yolo_data()
# init_yolo_data()
# 10. 创建菜单生成器命令行工具
create_menu_generator_command()
@@ -927,13 +917,15 @@ def init_database():
print(f"密码: hertz")
print(f"邮箱: admin@hertz.com")
print("")
print("YOLO模型配置:")
print(f"模型路径: static/models/yolov12/weights/best.pt")
print("")
print("菜单生成器工具:")
print(f"使用命令: python generate_menu.py crud <模块名> <模型名>")
print("")
print("请妥善保管管理员账号信息!")
print("")
print("普通用户账号信息:")
print("用户名: demo")
print("密码: 123456")
except Exception as e:
print(f"数据库初始化失败: {str(e)}")
@@ -1097,7 +1089,6 @@ def check_database_exists():
db_path = Path(db_config['NAME'])
return db_path.exists()
else:
# 对于其他数据库类型,尝试连接来检查
try:
from django.db import connection
connection.ensure_connection()
@@ -1105,6 +1096,31 @@ def check_database_exists():
except Exception:
return False
def create_mysql_database_if_missing():
from django.conf import settings
db = settings.DATABASES['default']
if db['ENGINE'] != 'django.db.backends.mysql':
return False
name = db['NAME']
host = db.get('HOST') or 'localhost'
user = db.get('USER') or 'root'
password = db.get('PASSWORD') or ''
port = int(db.get('PORT') or 3306)
try:
import MySQLdb
except Exception:
return False
try:
conn = MySQLdb.connect(host=host, user=user, passwd=password, port=port)
cur = conn.cursor()
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
conn.commit()
cur.close()
conn.close()
return True
except Exception:
return False
def run_migrations():
"""
@@ -1187,6 +1203,7 @@ def main():
print("📊 步骤1: 检查数据库状态...")
if not check_database_exists():
print("❌ 数据库不存在,需要创建")
created = create_mysql_database_if_missing()
need_migration = True
else:
print("✅ 数据库文件存在")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -1,106 +0,0 @@
task: detect
mode: train
model: ultralytics/cfg/models/12/yolo12.yaml
data: data/data.yaml
epochs: 100
time: null
patience: 100
batch: 8
imgsz: 640
save: true
save_period: -1
cache: false
device: '0'
workers: 4
project: runs
name: 建筑2
exist_ok: false
pretrained: yolo12n.pt
optimizer: SGD
verbose: true
seed: 0
deterministic: true
single_cls: false
rect: false
cos_lr: false
close_mosaic: 0
resume: false
amp: true
fraction: 1.0
profile: false
freeze: null
multi_scale: false
compile: false
overlap_mask: true
mask_ratio: 4
dropout: 0.0
val: true
split: val
save_json: false
conf: null
iou: 0.7
max_det: 300
half: false
dnn: false
plots: true
source: null
vid_stride: 1
stream_buffer: false
visualize: false
augment: false
agnostic_nms: false
classes: null
retina_masks: false
embed: null
show: false
save_frames: false
save_txt: false
save_conf: false
save_crop: false
show_labels: true
show_conf: true
show_boxes: true
line_width: null
format: torchscript
keras: false
optimize: false
int8: false
dynamic: false
simplify: true
opset: null
workspace: null
nms: false
lr0: 0.01
lrf: 0.01
momentum: 0.937
weight_decay: 0.0005
warmup_epochs: 3.0
warmup_momentum: 0.8
warmup_bias_lr: 0.1
box: 7.5
cls: 0.5
dfl: 1.5
pose: 12.0
kobj: 1.0
nbs: 64
hsv_h: 0.015
hsv_s: 0.7
hsv_v: 0.4
degrees: 0.0
translate: 0.1
scale: 0.5
shear: 0.0
perspective: 0.0
flipud: 0.0
fliplr: 0.5
bgr: 0.0
mosaic: 1.0
mixup: 0.0
cutmix: 0.0
copy_paste: 0.0
copy_paste_mode: flip
auto_augment: randaugment
erasing: 0.4
cfg: null
tracker: botsort.yaml
save_dir: C:\2025.8\train\YOLOv12\YOLOv12\runs\建筑2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -1,101 +0,0 @@
epoch,time,train/box_loss,train/cls_loss,train/dfl_loss,metrics/precision(B),metrics/recall(B),metrics/mAP50(B),metrics/mAP50-95(B),val/box_loss,val/cls_loss,val/dfl_loss,lr/pg0,lr/pg1,lr/pg2
1,20.0086,1.08731,3.29955,1.64504,0.48095,0.36519,0.39922,0.21803,1.4547,2.91476,2.19748,0.0701807,0.00331325,0.00331325
2,37.4561,1.10527,2.42227,1.62543,0.45751,0.44294,0.42037,0.22736,1.58274,2.95344,2.38848,0.0401149,0.00658079,0.00658079
3,53.5831,1.18287,2.1408,1.65928,0.44406,0.5402,0.53045,0.267,1.44637,3.10211,2.38715,0.00998312,0.00978232,0.00978232
4,69.7641,1.2564,2.15102,1.71232,0.42506,0.47244,0.42852,0.22216,1.46367,2.98094,2.31821,0.009703,0.009703,0.009703
5,85.9353,1.23832,2.04544,1.69687,0.56321,0.63046,0.57009,0.29777,1.53055,1.90867,2.26696,0.009604,0.009604,0.009604
6,101.832,1.19918,1.92689,1.67477,0.70549,0.61898,0.69763,0.38758,1.43295,1.68339,2.16059,0.009505,0.009505,0.009505
7,117.773,1.22633,1.84729,1.6803,0.553,0.67332,0.66166,0.34056,1.58437,1.59916,2.26187,0.009406,0.009406,0.009406
8,133.716,1.15681,1.7694,1.62475,0.65653,0.68416,0.68772,0.355,1.49044,1.58497,2.2459,0.009307,0.009307,0.009307
9,149.682,1.1569,1.69483,1.62545,0.7688,0.67236,0.77061,0.45366,1.32424,1.32885,1.98086,0.009208,0.009208,0.009208
10,165.68,1.15321,1.61623,1.61874,0.69611,0.75215,0.75817,0.4151,1.43079,1.33073,2.13068,0.009109,0.009109,0.009109
11,181.822,1.14882,1.53541,1.59886,0.75853,0.7246,0.78361,0.48015,1.24253,1.28601,1.92595,0.00901,0.00901,0.00901
12,197.889,1.13351,1.49226,1.60018,0.71891,0.78305,0.78574,0.46692,1.30974,1.17806,1.93744,0.008911,0.008911,0.008911
13,213.936,1.12,1.44936,1.57138,0.76987,0.77484,0.83633,0.49014,1.30538,1.10903,1.96262,0.008812,0.008812,0.008812
14,229.905,1.09812,1.40376,1.56604,0.85048,0.82277,0.86213,0.53598,1.24135,1.00046,1.90721,0.008713,0.008713,0.008713
15,245.888,1.0883,1.36922,1.55153,0.81135,0.79338,0.86585,0.49846,1.27613,1.06052,1.92691,0.008614,0.008614,0.008614
16,261.957,1.07151,1.3544,1.54787,0.79833,0.82635,0.87057,0.53621,1.21024,1.04704,1.82224,0.008515,0.008515,0.008515
17,277.964,1.05532,1.29671,1.52115,0.82433,0.84293,0.87414,0.55596,1.19172,0.93791,1.79585,0.008416,0.008416,0.008416
18,293.854,1.06292,1.25445,1.51939,0.80981,0.80042,0.85706,0.53513,1.20435,0.9763,1.85792,0.008317,0.008317,0.008317
19,309.903,1.04243,1.26345,1.51501,0.82473,0.78456,0.85556,0.52423,1.26536,1.09649,1.85899,0.008218,0.008218,0.008218
20,325.895,1.02684,1.22845,1.49715,0.78029,0.8402,0.87547,0.56629,1.21743,0.93182,1.78994,0.008119,0.008119,0.008119
21,341.856,1.00893,1.21206,1.49205,0.8397,0.81854,0.88349,0.57237,1.16211,0.90322,1.72301,0.00802,0.00802,0.00802
22,357.71,1.04323,1.16636,1.51546,0.82415,0.80944,0.85666,0.56374,1.12557,0.88955,1.72481,0.007921,0.007921,0.007921
23,373.601,1.01213,1.16652,1.48291,0.89755,0.84814,0.92614,0.61678,1.0727,0.82181,1.66517,0.007822,0.007822,0.007822
24,389.775,1.01485,1.1677,1.49783,0.82869,0.82688,0.89032,0.57284,1.15754,0.86914,1.75619,0.007723,0.007723,0.007723
25,405.662,1.0297,1.14486,1.4959,0.85883,0.86808,0.90549,0.58164,1.18685,0.90579,1.7652,0.007624,0.007624,0.007624
26,421.518,1.00351,1.12364,1.48466,0.91116,0.86016,0.90481,0.59292,1.16494,0.82749,1.7473,0.007525,0.007525,0.007525
27,437.468,0.99466,1.10594,1.48,0.88077,0.87488,0.91148,0.59708,1.12196,0.86732,1.66219,0.007426,0.007426,0.007426
28,453.355,0.97246,1.09983,1.45414,0.8509,0.88423,0.92609,0.59958,1.08857,0.82737,1.66404,0.007327,0.007327,0.007327
29,469.354,0.959,1.03722,1.43833,0.87257,0.89723,0.9292,0.63013,1.05661,0.79721,1.65584,0.007228,0.007228,0.007228
30,485.279,0.94641,1.0268,1.43645,0.87653,0.87173,0.92362,0.6152,1.09542,0.75908,1.668,0.007129,0.007129,0.007129
31,501.258,0.96459,1.0295,1.4477,0.85852,0.85356,0.91341,0.60373,1.10817,0.84379,1.65368,0.00703,0.00703,0.00703
32,517.168,0.9447,1.04712,1.43485,0.89923,0.89345,0.93876,0.62992,1.08927,0.72365,1.6683,0.006931,0.006931,0.006931
33,533.108,0.96199,1.00588,1.44815,0.87076,0.88306,0.91325,0.6119,1.10563,0.7969,1.66875,0.006832,0.006832,0.006832
34,549.012,0.93536,0.99021,1.42286,0.8548,0.89202,0.91598,0.59513,1.07788,0.79095,1.64711,0.006733,0.006733,0.006733
35,564.887,0.94238,1.03363,1.43755,0.89576,0.89663,0.94787,0.63072,1.08729,0.71356,1.64567,0.006634,0.006634,0.006634
36,580.805,0.94078,0.96053,1.41823,0.89222,0.9035,0.93538,0.64363,1.04054,0.67852,1.59891,0.006535,0.006535,0.006535
37,596.784,0.94003,0.94652,1.42158,0.88453,0.88348,0.94349,0.64272,1.06216,0.73028,1.60311,0.006436,0.006436,0.006436
38,612.423,0.92788,0.95855,1.42125,0.88936,0.91974,0.93403,0.6147,1.08269,0.72013,1.61904,0.006337,0.006337,0.006337
39,628.076,0.9224,0.94874,1.40102,0.89073,0.9069,0.92887,0.63473,1.0353,0.69181,1.58564,0.006238,0.006238,0.006238
40,643.77,0.89775,0.94759,1.39744,0.90121,0.88407,0.9443,0.65067,1.0286,0.7247,1.55336,0.006139,0.006139,0.006139
41,659.544,0.92422,0.92201,1.4103,0.91164,0.92449,0.95193,0.65305,1.02727,0.65603,1.55055,0.00604,0.00604,0.00604
42,675.22,0.90217,0.95037,1.40697,0.90345,0.88679,0.93082,0.63708,1.03697,0.69057,1.54943,0.005941,0.005941,0.005941
43,690.878,0.87991,0.89616,1.37762,0.92201,0.88928,0.95304,0.65937,0.96974,0.67072,1.51294,0.005842,0.005842,0.005842
44,706.637,0.88529,0.89891,1.38099,0.90464,0.91058,0.95737,0.64397,1.03433,0.65809,1.55709,0.005743,0.005743,0.005743
45,722.328,0.89267,0.88466,1.38184,0.89532,0.90185,0.93743,0.63438,1.02933,0.6711,1.56103,0.005644,0.005644,0.005644
46,738.079,0.87848,0.87502,1.38415,0.90984,0.90932,0.94224,0.65532,0.99621,0.65005,1.54755,0.005545,0.005545,0.005545
47,753.783,0.86936,0.86761,1.36124,0.90837,0.92106,0.95072,0.6533,1.00146,0.64086,1.54653,0.005446,0.005446,0.005446
48,769.457,0.88033,0.8537,1.37431,0.90748,0.90495,0.958,0.66689,1.02737,0.63689,1.55787,0.005347,0.005347,0.005347
49,785.148,0.86988,0.85134,1.36982,0.88654,0.91463,0.94567,0.65455,1.01259,0.64571,1.54479,0.005248,0.005248,0.005248
50,800.893,0.88317,0.8575,1.37127,0.91587,0.90751,0.94408,0.63446,1.02748,0.6757,1.5421,0.005149,0.005149,0.005149
51,816.553,0.87948,0.87439,1.37605,0.93772,0.92013,0.95022,0.65743,1.00579,0.63395,1.53519,0.00505,0.00505,0.00505
52,832.289,0.86492,0.83698,1.36435,0.92391,0.90861,0.95711,0.65756,1.04998,0.6317,1.57745,0.004951,0.004951,0.004951
53,848.093,0.85355,0.82554,1.35923,0.91881,0.94257,0.95839,0.66649,1.01111,0.61029,1.52965,0.004852,0.004852,0.004852
54,863.775,0.8535,0.83035,1.3572,0.91154,0.9155,0.94293,0.64606,1.02703,0.63016,1.54362,0.004753,0.004753,0.004753
55,879.543,0.85986,0.83531,1.35698,0.90236,0.93667,0.96041,0.67001,1.0152,0.63869,1.54171,0.004654,0.004654,0.004654
56,895.268,0.84045,0.79273,1.34856,0.89532,0.92493,0.95921,0.67304,1.00989,0.64012,1.54289,0.004555,0.004555,0.004555
57,911.011,0.82598,0.8,1.33521,0.91024,0.90031,0.96198,0.68098,0.98671,0.63172,1.52354,0.004456,0.004456,0.004456
58,926.714,0.84181,0.77648,1.34796,0.91338,0.93502,0.95999,0.67984,0.98774,0.59608,1.49965,0.004357,0.004357,0.004357
59,942.543,0.83298,0.78212,1.3426,0.94031,0.94731,0.96849,0.67095,1.00989,0.58775,1.52084,0.004258,0.004258,0.004258
60,958.212,0.83207,0.78794,1.33592,0.93456,0.93818,0.95865,0.67999,0.99628,0.58504,1.52259,0.004159,0.004159,0.004159
61,973.921,0.82934,0.76291,1.33786,0.94081,0.91384,0.94778,0.65941,1.01695,0.5969,1.526,0.00406,0.00406,0.00406
62,989.588,0.82385,0.76501,1.31992,0.93582,0.92592,0.95652,0.67726,0.99442,0.5805,1.52139,0.003961,0.003961,0.003961
63,1005.27,0.82826,0.74738,1.33216,0.92745,0.93277,0.95931,0.66606,0.98922,0.59454,1.50615,0.003862,0.003862,0.003862
64,1021.05,0.80678,0.72549,1.32648,0.91708,0.9377,0.95986,0.66584,0.99548,0.59683,1.52291,0.003763,0.003763,0.003763
65,1036.89,0.81482,0.72911,1.32164,0.93121,0.93781,0.95586,0.67407,1.0136,0.57583,1.54121,0.003664,0.003664,0.003664
66,1052.64,0.79386,0.71331,1.30697,0.93732,0.93754,0.96547,0.68623,0.97395,0.56161,1.49551,0.003565,0.003565,0.003565
67,1068.43,0.79758,0.71484,1.31935,0.91717,0.93342,0.96536,0.68752,0.97895,0.57296,1.50173,0.003466,0.003466,0.003466
68,1084.23,0.79346,0.71871,1.31773,0.91666,0.95392,0.95066,0.6665,1.01352,0.57563,1.53215,0.003367,0.003367,0.003367
69,1100,0.78618,0.71187,1.31807,0.94808,0.945,0.97505,0.69891,0.96108,0.54733,1.46854,0.003268,0.003268,0.003268
70,1115.68,0.79627,0.69645,1.30445,0.92507,0.96131,0.96442,0.69506,0.97216,0.55866,1.48312,0.003169,0.003169,0.003169
71,1131.46,0.76443,0.70608,1.29604,0.94985,0.9588,0.97657,0.70639,0.96117,0.54299,1.46808,0.00307,0.00307,0.00307
72,1147.25,0.78344,0.68905,1.30023,0.95568,0.95506,0.97787,0.69443,0.97204,0.53025,1.47856,0.002971,0.002971,0.002971
73,1163,0.76713,0.68651,1.28648,0.94512,0.93645,0.96365,0.6831,0.99244,0.54819,1.50867,0.002872,0.002872,0.002872
74,1178.73,0.78028,0.69619,1.30254,0.95322,0.94042,0.96886,0.68547,0.96242,0.55545,1.49758,0.002773,0.002773,0.002773
75,1194.43,0.77224,0.69314,1.29348,0.95179,0.93399,0.97174,0.69176,0.96883,0.55448,1.48271,0.002674,0.002674,0.002674
76,1210.25,0.75916,0.67695,1.2806,0.94949,0.94603,0.97058,0.70176,0.95191,0.54891,1.47248,0.002575,0.002575,0.002575
77,1225.98,0.74762,0.66489,1.28089,0.94943,0.94766,0.97236,0.69255,0.98364,0.55388,1.48251,0.002476,0.002476,0.002476
78,1241.66,0.77711,0.67409,1.2883,0.93344,0.95383,0.96868,0.67911,0.97057,0.54697,1.48187,0.002377,0.002377,0.002377
79,1257.35,0.74631,0.66574,1.27737,0.95085,0.94672,0.97652,0.69682,0.95676,0.54795,1.46933,0.002278,0.002278,0.002278
80,1273.15,0.75217,0.6506,1.28044,0.93861,0.96425,0.97701,0.6801,0.99862,0.53986,1.50843,0.002179,0.002179,0.002179
81,1288.83,0.74646,0.64203,1.28248,0.94694,0.95524,0.97378,0.69948,0.9393,0.53228,1.45059,0.00208,0.00208,0.00208
82,1304.49,0.76051,0.64159,1.29044,0.94279,0.96615,0.97642,0.71067,0.94991,0.52283,1.45191,0.001981,0.001981,0.001981
83,1320.18,0.74572,0.65008,1.26769,0.9648,0.95015,0.97473,0.69887,0.96052,0.53792,1.47686,0.001882,0.001882,0.001882
84,1335.93,0.73985,0.6404,1.26772,0.95366,0.94533,0.97208,0.70438,0.95165,0.52846,1.45992,0.001783,0.001783,0.001783
85,1352,0.74811,0.62183,1.27641,0.94718,0.9572,0.97351,0.70502,0.96988,0.52138,1.48555,0.001684,0.001684,0.001684
86,1367.67,0.74665,0.63662,1.2715,0.95091,0.95529,0.97714,0.70807,0.95641,0.5126,1.46246,0.001585,0.001585,0.001585
87,1383.33,0.71769,0.63289,1.2628,0.94342,0.9504,0.97286,0.69575,0.95349,0.52387,1.4662,0.001486,0.001486,0.001486
88,1399,0.73196,0.62775,1.26479,0.92059,0.96043,0.97218,0.69775,0.95618,0.53638,1.48112,0.001387,0.001387,0.001387
89,1414.78,0.70978,0.60203,1.24537,0.92296,0.95359,0.9652,0.69841,0.9556,0.51803,1.46562,0.001288,0.001288,0.001288
90,1430.43,0.72264,0.59842,1.25778,0.94076,0.9349,0.97077,0.69505,0.97915,0.53102,1.48364,0.001189,0.001189,0.001189
91,1446.11,0.69944,0.60885,1.23989,0.95493,0.95098,0.97538,0.71147,0.95821,0.50546,1.45932,0.00109,0.00109,0.00109
92,1461.79,0.7136,0.58259,1.25125,0.93353,0.95799,0.97267,0.70024,0.97099,0.51186,1.47324,0.000991,0.000991,0.000991
93,1477.44,0.71531,0.6023,1.2503,0.94569,0.95561,0.97551,0.71119,0.96279,0.49675,1.45994,0.000892,0.000892,0.000892
94,1493.19,0.70482,0.59368,1.24567,0.95518,0.94865,0.97496,0.71021,0.96272,0.50238,1.46949,0.000793,0.000793,0.000793
95,1508.84,0.70726,0.6036,1.2466,0.93383,0.9483,0.97548,0.70424,0.96704,0.50892,1.47366,0.000694,0.000694,0.000694
96,1524.51,0.69484,0.58516,1.23633,0.94152,0.95618,0.97524,0.70887,0.96193,0.50233,1.46647,0.000595,0.000595,0.000595
97,1540.23,0.702,0.57676,1.24112,0.94631,0.94809,0.9713,0.70927,0.95388,0.50872,1.45941,0.000496,0.000496,0.000496
98,1555.97,0.7011,0.56706,1.23848,0.94278,0.95722,0.97388,0.71225,0.95844,0.50285,1.46779,0.000397,0.000397,0.000397
99,1571.61,0.68355,0.5717,1.24052,0.94328,0.94665,0.97367,0.70978,0.95599,0.50202,1.45859,0.000298,0.000298,0.000298
100,1587.33,0.68724,0.5822,1.22946,0.94701,0.95346,0.97446,0.71402,0.95374,0.50433,1.46168,0.000199,0.000199,0.000199
1 epoch time train/box_loss train/cls_loss train/dfl_loss metrics/precision(B) metrics/recall(B) metrics/mAP50(B) metrics/mAP50-95(B) val/box_loss val/cls_loss val/dfl_loss lr/pg0 lr/pg1 lr/pg2
2 1 20.0086 1.08731 3.29955 1.64504 0.48095 0.36519 0.39922 0.21803 1.4547 2.91476 2.19748 0.0701807 0.00331325 0.00331325
3 2 37.4561 1.10527 2.42227 1.62543 0.45751 0.44294 0.42037 0.22736 1.58274 2.95344 2.38848 0.0401149 0.00658079 0.00658079
4 3 53.5831 1.18287 2.1408 1.65928 0.44406 0.5402 0.53045 0.267 1.44637 3.10211 2.38715 0.00998312 0.00978232 0.00978232
5 4 69.7641 1.2564 2.15102 1.71232 0.42506 0.47244 0.42852 0.22216 1.46367 2.98094 2.31821 0.009703 0.009703 0.009703
6 5 85.9353 1.23832 2.04544 1.69687 0.56321 0.63046 0.57009 0.29777 1.53055 1.90867 2.26696 0.009604 0.009604 0.009604
7 6 101.832 1.19918 1.92689 1.67477 0.70549 0.61898 0.69763 0.38758 1.43295 1.68339 2.16059 0.009505 0.009505 0.009505
8 7 117.773 1.22633 1.84729 1.6803 0.553 0.67332 0.66166 0.34056 1.58437 1.59916 2.26187 0.009406 0.009406 0.009406
9 8 133.716 1.15681 1.7694 1.62475 0.65653 0.68416 0.68772 0.355 1.49044 1.58497 2.2459 0.009307 0.009307 0.009307
10 9 149.682 1.1569 1.69483 1.62545 0.7688 0.67236 0.77061 0.45366 1.32424 1.32885 1.98086 0.009208 0.009208 0.009208
11 10 165.68 1.15321 1.61623 1.61874 0.69611 0.75215 0.75817 0.4151 1.43079 1.33073 2.13068 0.009109 0.009109 0.009109
12 11 181.822 1.14882 1.53541 1.59886 0.75853 0.7246 0.78361 0.48015 1.24253 1.28601 1.92595 0.00901 0.00901 0.00901
13 12 197.889 1.13351 1.49226 1.60018 0.71891 0.78305 0.78574 0.46692 1.30974 1.17806 1.93744 0.008911 0.008911 0.008911
14 13 213.936 1.12 1.44936 1.57138 0.76987 0.77484 0.83633 0.49014 1.30538 1.10903 1.96262 0.008812 0.008812 0.008812
15 14 229.905 1.09812 1.40376 1.56604 0.85048 0.82277 0.86213 0.53598 1.24135 1.00046 1.90721 0.008713 0.008713 0.008713
16 15 245.888 1.0883 1.36922 1.55153 0.81135 0.79338 0.86585 0.49846 1.27613 1.06052 1.92691 0.008614 0.008614 0.008614
17 16 261.957 1.07151 1.3544 1.54787 0.79833 0.82635 0.87057 0.53621 1.21024 1.04704 1.82224 0.008515 0.008515 0.008515
18 17 277.964 1.05532 1.29671 1.52115 0.82433 0.84293 0.87414 0.55596 1.19172 0.93791 1.79585 0.008416 0.008416 0.008416
19 18 293.854 1.06292 1.25445 1.51939 0.80981 0.80042 0.85706 0.53513 1.20435 0.9763 1.85792 0.008317 0.008317 0.008317
20 19 309.903 1.04243 1.26345 1.51501 0.82473 0.78456 0.85556 0.52423 1.26536 1.09649 1.85899 0.008218 0.008218 0.008218
21 20 325.895 1.02684 1.22845 1.49715 0.78029 0.8402 0.87547 0.56629 1.21743 0.93182 1.78994 0.008119 0.008119 0.008119
22 21 341.856 1.00893 1.21206 1.49205 0.8397 0.81854 0.88349 0.57237 1.16211 0.90322 1.72301 0.00802 0.00802 0.00802
23 22 357.71 1.04323 1.16636 1.51546 0.82415 0.80944 0.85666 0.56374 1.12557 0.88955 1.72481 0.007921 0.007921 0.007921
24 23 373.601 1.01213 1.16652 1.48291 0.89755 0.84814 0.92614 0.61678 1.0727 0.82181 1.66517 0.007822 0.007822 0.007822
25 24 389.775 1.01485 1.1677 1.49783 0.82869 0.82688 0.89032 0.57284 1.15754 0.86914 1.75619 0.007723 0.007723 0.007723
26 25 405.662 1.0297 1.14486 1.4959 0.85883 0.86808 0.90549 0.58164 1.18685 0.90579 1.7652 0.007624 0.007624 0.007624
27 26 421.518 1.00351 1.12364 1.48466 0.91116 0.86016 0.90481 0.59292 1.16494 0.82749 1.7473 0.007525 0.007525 0.007525
28 27 437.468 0.99466 1.10594 1.48 0.88077 0.87488 0.91148 0.59708 1.12196 0.86732 1.66219 0.007426 0.007426 0.007426
29 28 453.355 0.97246 1.09983 1.45414 0.8509 0.88423 0.92609 0.59958 1.08857 0.82737 1.66404 0.007327 0.007327 0.007327
30 29 469.354 0.959 1.03722 1.43833 0.87257 0.89723 0.9292 0.63013 1.05661 0.79721 1.65584 0.007228 0.007228 0.007228
31 30 485.279 0.94641 1.0268 1.43645 0.87653 0.87173 0.92362 0.6152 1.09542 0.75908 1.668 0.007129 0.007129 0.007129
32 31 501.258 0.96459 1.0295 1.4477 0.85852 0.85356 0.91341 0.60373 1.10817 0.84379 1.65368 0.00703 0.00703 0.00703
33 32 517.168 0.9447 1.04712 1.43485 0.89923 0.89345 0.93876 0.62992 1.08927 0.72365 1.6683 0.006931 0.006931 0.006931
34 33 533.108 0.96199 1.00588 1.44815 0.87076 0.88306 0.91325 0.6119 1.10563 0.7969 1.66875 0.006832 0.006832 0.006832
35 34 549.012 0.93536 0.99021 1.42286 0.8548 0.89202 0.91598 0.59513 1.07788 0.79095 1.64711 0.006733 0.006733 0.006733
36 35 564.887 0.94238 1.03363 1.43755 0.89576 0.89663 0.94787 0.63072 1.08729 0.71356 1.64567 0.006634 0.006634 0.006634
37 36 580.805 0.94078 0.96053 1.41823 0.89222 0.9035 0.93538 0.64363 1.04054 0.67852 1.59891 0.006535 0.006535 0.006535
38 37 596.784 0.94003 0.94652 1.42158 0.88453 0.88348 0.94349 0.64272 1.06216 0.73028 1.60311 0.006436 0.006436 0.006436
39 38 612.423 0.92788 0.95855 1.42125 0.88936 0.91974 0.93403 0.6147 1.08269 0.72013 1.61904 0.006337 0.006337 0.006337
40 39 628.076 0.9224 0.94874 1.40102 0.89073 0.9069 0.92887 0.63473 1.0353 0.69181 1.58564 0.006238 0.006238 0.006238
41 40 643.77 0.89775 0.94759 1.39744 0.90121 0.88407 0.9443 0.65067 1.0286 0.7247 1.55336 0.006139 0.006139 0.006139
42 41 659.544 0.92422 0.92201 1.4103 0.91164 0.92449 0.95193 0.65305 1.02727 0.65603 1.55055 0.00604 0.00604 0.00604
43 42 675.22 0.90217 0.95037 1.40697 0.90345 0.88679 0.93082 0.63708 1.03697 0.69057 1.54943 0.005941 0.005941 0.005941
44 43 690.878 0.87991 0.89616 1.37762 0.92201 0.88928 0.95304 0.65937 0.96974 0.67072 1.51294 0.005842 0.005842 0.005842
45 44 706.637 0.88529 0.89891 1.38099 0.90464 0.91058 0.95737 0.64397 1.03433 0.65809 1.55709 0.005743 0.005743 0.005743
46 45 722.328 0.89267 0.88466 1.38184 0.89532 0.90185 0.93743 0.63438 1.02933 0.6711 1.56103 0.005644 0.005644 0.005644
47 46 738.079 0.87848 0.87502 1.38415 0.90984 0.90932 0.94224 0.65532 0.99621 0.65005 1.54755 0.005545 0.005545 0.005545
48 47 753.783 0.86936 0.86761 1.36124 0.90837 0.92106 0.95072 0.6533 1.00146 0.64086 1.54653 0.005446 0.005446 0.005446
49 48 769.457 0.88033 0.8537 1.37431 0.90748 0.90495 0.958 0.66689 1.02737 0.63689 1.55787 0.005347 0.005347 0.005347
50 49 785.148 0.86988 0.85134 1.36982 0.88654 0.91463 0.94567 0.65455 1.01259 0.64571 1.54479 0.005248 0.005248 0.005248
51 50 800.893 0.88317 0.8575 1.37127 0.91587 0.90751 0.94408 0.63446 1.02748 0.6757 1.5421 0.005149 0.005149 0.005149
52 51 816.553 0.87948 0.87439 1.37605 0.93772 0.92013 0.95022 0.65743 1.00579 0.63395 1.53519 0.00505 0.00505 0.00505
53 52 832.289 0.86492 0.83698 1.36435 0.92391 0.90861 0.95711 0.65756 1.04998 0.6317 1.57745 0.004951 0.004951 0.004951
54 53 848.093 0.85355 0.82554 1.35923 0.91881 0.94257 0.95839 0.66649 1.01111 0.61029 1.52965 0.004852 0.004852 0.004852
55 54 863.775 0.8535 0.83035 1.3572 0.91154 0.9155 0.94293 0.64606 1.02703 0.63016 1.54362 0.004753 0.004753 0.004753
56 55 879.543 0.85986 0.83531 1.35698 0.90236 0.93667 0.96041 0.67001 1.0152 0.63869 1.54171 0.004654 0.004654 0.004654
57 56 895.268 0.84045 0.79273 1.34856 0.89532 0.92493 0.95921 0.67304 1.00989 0.64012 1.54289 0.004555 0.004555 0.004555
58 57 911.011 0.82598 0.8 1.33521 0.91024 0.90031 0.96198 0.68098 0.98671 0.63172 1.52354 0.004456 0.004456 0.004456
59 58 926.714 0.84181 0.77648 1.34796 0.91338 0.93502 0.95999 0.67984 0.98774 0.59608 1.49965 0.004357 0.004357 0.004357
60 59 942.543 0.83298 0.78212 1.3426 0.94031 0.94731 0.96849 0.67095 1.00989 0.58775 1.52084 0.004258 0.004258 0.004258
61 60 958.212 0.83207 0.78794 1.33592 0.93456 0.93818 0.95865 0.67999 0.99628 0.58504 1.52259 0.004159 0.004159 0.004159
62 61 973.921 0.82934 0.76291 1.33786 0.94081 0.91384 0.94778 0.65941 1.01695 0.5969 1.526 0.00406 0.00406 0.00406
63 62 989.588 0.82385 0.76501 1.31992 0.93582 0.92592 0.95652 0.67726 0.99442 0.5805 1.52139 0.003961 0.003961 0.003961
64 63 1005.27 0.82826 0.74738 1.33216 0.92745 0.93277 0.95931 0.66606 0.98922 0.59454 1.50615 0.003862 0.003862 0.003862
65 64 1021.05 0.80678 0.72549 1.32648 0.91708 0.9377 0.95986 0.66584 0.99548 0.59683 1.52291 0.003763 0.003763 0.003763
66 65 1036.89 0.81482 0.72911 1.32164 0.93121 0.93781 0.95586 0.67407 1.0136 0.57583 1.54121 0.003664 0.003664 0.003664
67 66 1052.64 0.79386 0.71331 1.30697 0.93732 0.93754 0.96547 0.68623 0.97395 0.56161 1.49551 0.003565 0.003565 0.003565
68 67 1068.43 0.79758 0.71484 1.31935 0.91717 0.93342 0.96536 0.68752 0.97895 0.57296 1.50173 0.003466 0.003466 0.003466
69 68 1084.23 0.79346 0.71871 1.31773 0.91666 0.95392 0.95066 0.6665 1.01352 0.57563 1.53215 0.003367 0.003367 0.003367
70 69 1100 0.78618 0.71187 1.31807 0.94808 0.945 0.97505 0.69891 0.96108 0.54733 1.46854 0.003268 0.003268 0.003268
71 70 1115.68 0.79627 0.69645 1.30445 0.92507 0.96131 0.96442 0.69506 0.97216 0.55866 1.48312 0.003169 0.003169 0.003169
72 71 1131.46 0.76443 0.70608 1.29604 0.94985 0.9588 0.97657 0.70639 0.96117 0.54299 1.46808 0.00307 0.00307 0.00307
73 72 1147.25 0.78344 0.68905 1.30023 0.95568 0.95506 0.97787 0.69443 0.97204 0.53025 1.47856 0.002971 0.002971 0.002971
74 73 1163 0.76713 0.68651 1.28648 0.94512 0.93645 0.96365 0.6831 0.99244 0.54819 1.50867 0.002872 0.002872 0.002872
75 74 1178.73 0.78028 0.69619 1.30254 0.95322 0.94042 0.96886 0.68547 0.96242 0.55545 1.49758 0.002773 0.002773 0.002773
76 75 1194.43 0.77224 0.69314 1.29348 0.95179 0.93399 0.97174 0.69176 0.96883 0.55448 1.48271 0.002674 0.002674 0.002674
77 76 1210.25 0.75916 0.67695 1.2806 0.94949 0.94603 0.97058 0.70176 0.95191 0.54891 1.47248 0.002575 0.002575 0.002575
78 77 1225.98 0.74762 0.66489 1.28089 0.94943 0.94766 0.97236 0.69255 0.98364 0.55388 1.48251 0.002476 0.002476 0.002476
79 78 1241.66 0.77711 0.67409 1.2883 0.93344 0.95383 0.96868 0.67911 0.97057 0.54697 1.48187 0.002377 0.002377 0.002377
80 79 1257.35 0.74631 0.66574 1.27737 0.95085 0.94672 0.97652 0.69682 0.95676 0.54795 1.46933 0.002278 0.002278 0.002278
81 80 1273.15 0.75217 0.6506 1.28044 0.93861 0.96425 0.97701 0.6801 0.99862 0.53986 1.50843 0.002179 0.002179 0.002179
82 81 1288.83 0.74646 0.64203 1.28248 0.94694 0.95524 0.97378 0.69948 0.9393 0.53228 1.45059 0.00208 0.00208 0.00208
83 82 1304.49 0.76051 0.64159 1.29044 0.94279 0.96615 0.97642 0.71067 0.94991 0.52283 1.45191 0.001981 0.001981 0.001981
84 83 1320.18 0.74572 0.65008 1.26769 0.9648 0.95015 0.97473 0.69887 0.96052 0.53792 1.47686 0.001882 0.001882 0.001882
85 84 1335.93 0.73985 0.6404 1.26772 0.95366 0.94533 0.97208 0.70438 0.95165 0.52846 1.45992 0.001783 0.001783 0.001783
86 85 1352 0.74811 0.62183 1.27641 0.94718 0.9572 0.97351 0.70502 0.96988 0.52138 1.48555 0.001684 0.001684 0.001684
87 86 1367.67 0.74665 0.63662 1.2715 0.95091 0.95529 0.97714 0.70807 0.95641 0.5126 1.46246 0.001585 0.001585 0.001585
88 87 1383.33 0.71769 0.63289 1.2628 0.94342 0.9504 0.97286 0.69575 0.95349 0.52387 1.4662 0.001486 0.001486 0.001486
89 88 1399 0.73196 0.62775 1.26479 0.92059 0.96043 0.97218 0.69775 0.95618 0.53638 1.48112 0.001387 0.001387 0.001387
90 89 1414.78 0.70978 0.60203 1.24537 0.92296 0.95359 0.9652 0.69841 0.9556 0.51803 1.46562 0.001288 0.001288 0.001288
91 90 1430.43 0.72264 0.59842 1.25778 0.94076 0.9349 0.97077 0.69505 0.97915 0.53102 1.48364 0.001189 0.001189 0.001189
92 91 1446.11 0.69944 0.60885 1.23989 0.95493 0.95098 0.97538 0.71147 0.95821 0.50546 1.45932 0.00109 0.00109 0.00109
93 92 1461.79 0.7136 0.58259 1.25125 0.93353 0.95799 0.97267 0.70024 0.97099 0.51186 1.47324 0.000991 0.000991 0.000991
94 93 1477.44 0.71531 0.6023 1.2503 0.94569 0.95561 0.97551 0.71119 0.96279 0.49675 1.45994 0.000892 0.000892 0.000892
95 94 1493.19 0.70482 0.59368 1.24567 0.95518 0.94865 0.97496 0.71021 0.96272 0.50238 1.46949 0.000793 0.000793 0.000793
96 95 1508.84 0.70726 0.6036 1.2466 0.93383 0.9483 0.97548 0.70424 0.96704 0.50892 1.47366 0.000694 0.000694 0.000694
97 96 1524.51 0.69484 0.58516 1.23633 0.94152 0.95618 0.97524 0.70887 0.96193 0.50233 1.46647 0.000595 0.000595 0.000595
98 97 1540.23 0.702 0.57676 1.24112 0.94631 0.94809 0.9713 0.70927 0.95388 0.50872 1.45941 0.000496 0.000496 0.000496
99 98 1555.97 0.7011 0.56706 1.23848 0.94278 0.95722 0.97388 0.71225 0.95844 0.50285 1.46779 0.000397 0.000397 0.000397
100 99 1571.61 0.68355 0.5717 1.24052 0.94328 0.94665 0.97367 0.70978 0.95599 0.50202 1.45859 0.000298 0.000298 0.000298
101 100 1587.33 0.68724 0.5822 1.22946 0.94701 0.95346 0.97446 0.71402 0.95374 0.50433 1.46168 0.000199 0.000199 0.000199

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 KiB

View File

@@ -7,6 +7,7 @@
- `Python 3.10+`(建议 3.12.3
- 操作系统Windows
- redis本地 `Redis` 服务(默认地址 `redis://127.0.0.1:6379`
- mysql(支持切换mysql):在.env文件里面将mysql配置修改为你的数据库信息
@@ -36,7 +37,7 @@ pip install hertz-studio-django-yolo -i https://hertz:hertz@hzpypi.hzsystems.cn/
### 2下载全部库
pip install -r requirements.txt -i https://hzpypi.hzsystems.cn/simple/
pip install -r requirements.txt -i https://hertz:hertz@hzpypi.hzsystems.cn/simple/
@@ -130,4 +131,4 @@ ffmpeg压缩包在根目录下面的static目录下如下图。
## 九、**快速启动**
- 安装依赖:`pip install -r requirements.txt -i https://hertz:hertz@hzpypi.hzsystems.cn/simple/`
- 启动服务:`python start_server.py --port 8000`
- 启动服务:`python start_server.py`