更新
This commit is contained in:
25
hertz_server_django_ui/.editorconfig
Normal file
25
hertz_server_django_ui/.editorconfig
Normal file
@@ -0,0 +1,25 @@
|
||||
# EditorConfig配置文件
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{js,ts,vue}]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.{css,scss,sass}]
|
||||
indent_size = 2
|
||||
10
hertz_server_django_ui/.env
Normal file
10
hertz_server_django_ui/.env
Normal file
@@ -0,0 +1,10 @@
|
||||
# API 基础地址
|
||||
VITE_API_BASE_URL=http://192.168.124.23:8000
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=Hertz Admin
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# 开发服务器配置
|
||||
VITE_DEV_SERVER_HOST=localhost
|
||||
VITE_DEV_SERVER_PORT=3000
|
||||
2
hertz_server_django_ui/.env.development
Normal file
2
hertz_server_django_ui/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_TEMPLATE_SETUP_MODE=true
|
||||
2
hertz_server_django_ui/.env.production
Normal file
2
hertz_server_django_ui/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://192.168.124.40:8002
|
||||
VITE_TEMPLATE_SETUP_MODE=true
|
||||
24
hertz_server_django_ui/.gitignore
vendored
Normal file
24
hertz_server_django_ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
327
hertz_server_django_ui/README.md
Normal file
327
hertz_server_django_ui/README.md
Normal file
@@ -0,0 +1,327 @@
|
||||
<div align="center">
|
||||
|
||||
<h1>通用大模型模板 · Hertz Admin + AI</h1>
|
||||
|
||||
现代化的管理后台前端模板,面向二次开发的前端工程师。内置账号体系、权限路由、主题美化、知识库、YOLO 模型全流程(管理 / 类别 / 告警 / 历史)等典型模块。
|
||||
|
||||
<p>
|
||||
基于 Vite + Vue 3 + TypeScript + Ant Design Vue + Pinia + Vue Router 构建
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性(面向前端)
|
||||
|
||||
- **工程化完善**:TS 强类型、模块化 API、统一请求封装、权限化菜单/路由
|
||||
- **设计统一**:全局“超现代风格”主题,卡片 / 弹窗 / 按钮 / 输入 / 分页风格一致
|
||||
- **业务可复用**:
|
||||
- 文章管理:分类树 + 列表搜索 + 编辑/发布
|
||||
- YOLO 模型:模型管理、模型类别管理、告警处理中心、检测历史管理
|
||||
- AI 助手:多会话列表 + 消息记录 + 多布局对话界面(含错误调试信息)
|
||||
- 认证体系:登录/注册、验证码
|
||||
- **可扩展**:清晰的目录划分和命名规范,方便直接加模块或替换现有实现
|
||||
|
||||
## 🧩 技术栈
|
||||
|
||||
- 构建:Vite
|
||||
- 语言:TypeScript
|
||||
- 框架:Vue 3(Composition API)
|
||||
- UI:Ant Design Vue
|
||||
- 状态:Pinia
|
||||
- 路由:Vue Router
|
||||
|
||||
## 📦 项目结构与职责
|
||||
|
||||
> 根目录:`通用大模型模板/`
|
||||
|
||||
```bash
|
||||
通用大模型模板/
|
||||
└─ hertz_server_diango_ui_2/ # 前端工程(Vite)
|
||||
├─ public/ # 公共静态资源(不走打包器)
|
||||
├─ src/
|
||||
│ ├─ api/ # 接口定义(auth / yolo / knowledge / captcha / ai ...)
|
||||
│ │ └─ yolo.ts # YOLO 模型 & 检测 & 类别相关 API
|
||||
│ ├─ locales/ # 国际化文案
|
||||
│ ├─ router/ # 路由与菜单配置
|
||||
│ │ ├─ admin_menu.ts # 管理端菜单 + 路由映射(权限 key)
|
||||
│ │ ├─ user_menu_ai.ts # 用户端菜单 + 路由映射(含 AI 助手)
|
||||
│ │ └─ index.ts # Vue Router 实例 + 全局路由守卫
|
||||
│ ├─ stores/ # Pinia Store
|
||||
│ │ ├─ hertz_app.ts # 全局应用设置(语言、布局、菜单折叠等)
|
||||
│ │ ├─ hertz_user.ts # 用户 / 鉴权状态
|
||||
│ │ └─ hertz_theme.ts # 主题配置与 CSS 变量
|
||||
│ ├─ styles/ # 全局样式与变量
|
||||
│ │ ├─ index.scss # 全局组件风格覆盖(Button / Table / Modal ...)
|
||||
│ │ └─ variables.scss # 主题色、阴影、圆角等变量
|
||||
│ ├─ utils/ # 工具方法 & 基础设施
|
||||
│ │ ├─ hertz_request.ts # Axios 封装(baseURL、拦截器、错误提示)
|
||||
│ │ ├─ hertz_url.ts # 统一 URL 构造(API / 媒体 / WebSocket)
|
||||
│ │ ├─ hertz_env.ts # 读取 & 校验 env 变量
|
||||
│ │ └─ hertz_router_utils.ts # 路由相关工具 & 调试
|
||||
│ ├─ views/ # 所有页面
|
||||
│ │ ├─ admin_page/ # 管理端页面
|
||||
│ │ │ ├─ ModelManagement.vue # YOLO 模型管理
|
||||
│ │ │ ├─ AlertLevelManagement.vue # 模型类别管理
|
||||
│ │ │ ├─ DetectionHistoryManagement.vue # 检测历史管理
|
||||
│ │ │ └─ ... # 其他管理端模块
|
||||
│ │ ├─ user_pages/ # 用户端页面(检测端 + AI 助手)
|
||||
│ │ │ ├─ index.vue # 用户端主布局 + 顶部导航
|
||||
│ │ │ ├─ AiChat.vue # AI 助手对话页面
|
||||
│ │ │ ├─ YoloDetection.vue # 离线检测页面
|
||||
│ │ │ ├─ LiveDetection.vue # 实时检测页面(WebSocket)
|
||||
│ │ │ └─ ... # 告警中心 / 通知中心 / 知识库等
|
||||
│ │ ├─ Login.vue # 登录页
|
||||
│ │ └─ register.vue # 注册页
|
||||
│ ├─ App.vue # 应用根组件
|
||||
│ └─ main.ts # 入口文件(挂载 Vue / 路由 / Pinia)
|
||||
├─ .env.development # 开发环境变量(前端专用)
|
||||
├─ .env.production # 生产构建环境变量
|
||||
├─ vite.config.ts # Vite 配置(代理、构建、别名等)
|
||||
└─ package.json
|
||||
```
|
||||
|
||||
## 📁 文件与命名规范(建议)
|
||||
|
||||
- **组件 / 页面**
|
||||
- 页面:`src/views/admin_page/FooBarManagement.vue`,以业务 + Management 命名
|
||||
- 纯组件:放到 `src/components/`,使用大驼峰命名,如 `UserSelector.vue`
|
||||
- **接口文件**
|
||||
- 同一业务一个文件:`src/api/yolo.ts`、`src/api/auth.ts`
|
||||
- 内部导出 `xxxApi` 对象 + TS 类型:`type AlertLevel`, `type YoloModel` 等
|
||||
- **样式**
|
||||
- 全局或主题相关:放 `src/styles/`(注意不要在这里写页面私有样式)
|
||||
- 单页面样式:使用 `<style scoped lang="scss">` 写在对应 `.vue` 内
|
||||
- **工具函数**
|
||||
- 通用工具:`src/utils/` 下按领域拆分,如 `hertz_url.ts`、`hertz_env.ts`
|
||||
|
||||
## 🌐 后端 IP / 域名配置指引(前端视角最重要)
|
||||
|
||||
当前工程已经统一了后端地址配置,只需要 **改 2 个地方**:
|
||||
|
||||
1. **环境变量文件**(推荐只改这个)
|
||||
|
||||
- `hertz_server_diango_ui_2/.env.development`
|
||||
- `hertz_server_diango_ui_2/.env.production`
|
||||
|
||||
两个文件里都有一行:
|
||||
|
||||
```bash
|
||||
# 示例:开发环境
|
||||
VITE_API_BASE_URL=http://192.168.124.40:8022
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- **只写协议 + 域名/IP + 端口**,不要包含 `/api`
|
||||
- ✅ `http://192.168.124.40:8022`
|
||||
- ❌ `http://192.168.124.40:8022/api`
|
||||
- 开发与生产可指向不同后端,只要保证同样的接口路径即可。
|
||||
|
||||
2. **Vite 代理 & URL 工具**(已接好,通常不用改)
|
||||
|
||||
- `vite.config.ts`
|
||||
- 利用 `loadEnv` 读取 `VITE_API_BASE_URL`,自动去掉末尾 `/`:
|
||||
|
||||
```ts
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
const backendOrigin = apiBaseUrl.replace(/\/+$/, '')
|
||||
```
|
||||
|
||||
- 开发环境通过代理转发:
|
||||
|
||||
```ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: backendOrigin, changeOrigin: true },
|
||||
'/media': { target: backendOrigin, changeOrigin: true }
|
||||
}
|
||||
}
|
||||
|
||||
define: {
|
||||
__VITE_API_BASE_URL__: JSON.stringify(`${backendOrigin}/api`)
|
||||
}
|
||||
```
|
||||
|
||||
- `src/utils/hertz_url.ts`
|
||||
|
||||
- 统一获取后端基础地址:
|
||||
|
||||
```ts
|
||||
export function getBackendBaseUrl(): string {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
}
|
||||
```
|
||||
|
||||
- 构造 HTTP / WebSocket / 媒体地址:
|
||||
|
||||
```ts
|
||||
export function getApiBaseUrl() {
|
||||
return import.meta.env.DEV ? '' : getBackendBaseUrl()
|
||||
}
|
||||
|
||||
export function getMediaBaseUrl() {
|
||||
if (import.meta.env.DEV) return ''
|
||||
return getBackendBaseUrl().replace('/api', '')
|
||||
}
|
||||
|
||||
export function getFullFileUrl(relativePath: string) {
|
||||
const baseURL = getBackendBaseUrl()
|
||||
return `${baseURL}${relativePath}`
|
||||
}
|
||||
```
|
||||
|
||||
- `src/utils/hertz_request.ts`
|
||||
|
||||
- Axios 实例的 `baseURL` 在开发环境为空字符串(走 Vite 代理);生产环境使用 `VITE_API_BASE_URL`:
|
||||
|
||||
```ts
|
||||
const isDev = import.meta.env.DEV
|
||||
const baseURL = isDev ? '' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000')
|
||||
```
|
||||
|
||||
👉 **结论:前端同事只需要改 `.env.development` 和 `.env.production` 里的 `VITE_API_BASE_URL`,其余 URL 都通过工具/代理自动生效,无需到处搜 `localhost`。**
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
```bash
|
||||
# 进入工程目录
|
||||
cd hertz_server_diango_ui_2
|
||||
|
||||
# 安装依赖
|
||||
npm i
|
||||
|
||||
# 开发启动(默认 http://localhost:3001)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🔧 关键模块速览
|
||||
|
||||
- **主题与 Design System**
|
||||
- 入口:`src/styles/index.scss`、`src/styles/variables.scss`
|
||||
- 内容:按钮 / 表格 / 弹窗 / 输入框 等统一风格,含毛玻璃、hover、active、focus 细节
|
||||
|
||||
- **菜单与路由**
|
||||
- `src/router/admin_menu.ts`:单文件维护管理端菜单树 + 路由映射 + 权限标识
|
||||
- 面包屑逻辑已整理:不再重复展示“首页/”,只保留当前层级链路
|
||||
|
||||
- **YOLO 模块**
|
||||
- `ModelManagement.vue`:模型上传 / 列表 / 启用、拖拽上传区
|
||||
- `AlertLevelManagement.vue`:模型类别管理,支持单条 & 批量修改告警等级
|
||||
- `DetectionHistoryManagement.vue`:检测历史列表、图片/视频预览
|
||||
|
||||
- **认证模块**
|
||||
- API:`src/api/auth.ts`
|
||||
- 页面:`src/views/Login.vue`、`src/views/register.vue`
|
||||
- 注册表单字段已与后端约定一致:
|
||||
`username, password, confirm_password, email, phone, real_name, captcha, captcha_id`
|
||||
|
||||
## 🧪 常见问题(FAQ)
|
||||
|
||||
- **需要改哪些地方才能连上新的后端 IP?**
|
||||
- 只改:`.env.development` 和 `.env.production` 的 `VITE_API_BASE_URL`
|
||||
- 不需要:修改页面内的 `http://localhost:xxxx`,已统一收敛到工具函数
|
||||
|
||||
- **接口不走 / 返回字段对不上?**
|
||||
- 对比:`src/api/*.ts` 里定义的请求路径与 payload
|
||||
- 打开浏览器 Network 看真实请求 URL、body 与响应
|
||||
|
||||
- **页面样式和设计稿不一致?**
|
||||
- 先看 `src/styles/index.scss` 是否有全局覆盖
|
||||
- 再查对应 `.vue` 文件中的 scoped 样式是否有特殊处理
|
||||
|
||||
## 🛠️ 二次开发建议
|
||||
|
||||
- **新增管理模块**
|
||||
- 在 `src/views/admin_page/` 下新增页面,如 `FooBarManagement.vue`
|
||||
- 在 `src/router/admin_menu.ts` 中增加菜单配置(path + component + permission)
|
||||
|
||||
- **扩展接口**
|
||||
- 在 `src/api/` 新增 `xxx.ts`,导出 `xxxApi` 对象
|
||||
- 使用统一的 `request` 封装(`hertz_request.ts`),保持错误处理一致
|
||||
|
||||
- **改造主题 / 品牌色**
|
||||
- 修改 `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
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"prune": "node scripts/prune-modules.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
80
hertz_server_django_ui/components.d.ts
vendored
Normal file
80
hertz_server_django_ui/components.d.ts
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
|
||||
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
|
||||
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']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||
ADrawer: typeof import('ant-design-vue/es')['Drawer']
|
||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AImage: typeof import('ant-design-vue/es')['Image']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||
ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
|
||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
||||
AList: typeof import('ant-design-vue/es')['List']
|
||||
AListItem: typeof import('ant-design-vue/es')['ListItem']
|
||||
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
|
||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
|
||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APagination: typeof import('ant-design-vue/es')['Pagination']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||
ASlider: typeof import('ant-design-vue/es')['Slider']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
||||
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATimeline: typeof import('ant-design-vue/es')['Timeline']
|
||||
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATree: typeof import('ant-design-vue/es')['Tree']
|
||||
ATreeSelect: typeof import('ant-design-vue/es')['TreeSelect']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
81
hertz_server_django_ui/eslint.config.js
Normal file
81
hertz_server_django_ui/eslint.config.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import js from '@eslint/js'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default [
|
||||
// JavaScript 推荐规则
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript 推荐规则
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Vue 推荐规则
|
||||
...vue.configs['flat/recommended'],
|
||||
|
||||
// 项目特定配置
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
// 浏览器环境
|
||||
console: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
navigator: 'readonly',
|
||||
fetch: 'readonly',
|
||||
// Node.js环境
|
||||
process: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
global: 'readonly',
|
||||
// Vite环境
|
||||
import: 'readonly',
|
||||
// Vue环境
|
||||
Vue: 'readonly',
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// 禁用所有可能导致WebStorm警告的规则
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-debugger': 'off',
|
||||
'no-alert': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
|
||||
// Vue相关规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-unused-vars': 'off',
|
||||
'vue/no-unused-components': 'off',
|
||||
'vue/no-unused-properties': 'off',
|
||||
'vue/require-v-for-key': 'off',
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
|
||||
// TypeScript规则
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/prefer-const': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
}
|
||||
},
|
||||
|
||||
// 忽略文件
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'.git/**',
|
||||
'coverage/**',
|
||||
'*.config.js',
|
||||
'*.config.ts',
|
||||
]
|
||||
}
|
||||
]
|
||||
13
hertz_server_django_ui/index.html
Normal file
13
hertz_server_django_ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理系统模板</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5673
hertz_server_django_ui/package-lock.json
generated
Normal file
5673
hertz_server_django_ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
hertz_server_django_ui/package.json
Normal file
40
hertz_server_django_ui/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "hertz_server_django_ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"prune": "node scripts/prune-modules.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"ant-design-vue": "^3.2.20",
|
||||
"axios": "^1.12.2",
|
||||
"echarts": "^6.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"onnxruntime-web": "^1.23.2",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"sass-embedded": "^1.93.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"unplugin-vue-components": "^29.1.0",
|
||||
"vite": "^7.1.6",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
1
hertz_server_django_ui/public/models/manifest.json
Normal file
1
hertz_server_django_ui/public/models/manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
hertz_server_django_ui/public/vite.svg
Normal file
1
hertz_server_django_ui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
392
hertz_server_django_ui/scripts/prune-modules.mjs
Normal file
392
hertz_server_django_ui/scripts/prune-modules.mjs
Normal file
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// 一键裁剪脚本:根据功能模块删除或屏蔽对应的菜单配置和页面文件
|
||||
// 设计原则:
|
||||
// - 先通过运行时模块开关/页面确认要保留哪些模块
|
||||
// - 然后运行本脚本,选择要“裁剪掉”的模块,以及裁剪模式:
|
||||
// 1) 仅屏蔽(修改 moduleKey,使其永远不会被启用,保留页面文件)
|
||||
// 2) 删除(在 1 的基础上,再删除对应 .vue 页面文件)
|
||||
// - 脚本只操作前端代码,不影响后端
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import readline from 'readline'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
/** 模块定义(与 src/config/hertz_modules.ts 保持一致) */
|
||||
const MODULES = [
|
||||
{ key: 'admin.user-management', label: '管理端 · 用户管理', group: 'admin' },
|
||||
{ key: 'admin.department-management', label: '管理端 · 部门管理', group: 'admin' },
|
||||
{ key: 'admin.menu-management', label: '管理端 · 菜单管理', group: 'admin' },
|
||||
{ key: 'admin.role-management', label: '管理端 · 角色管理', group: 'admin' },
|
||||
{ key: 'admin.notification-management', label: '管理端 · 通知管理', group: 'admin' },
|
||||
{ key: 'admin.log-management', label: '管理端 · 日志管理', group: 'admin' },
|
||||
{ key: 'admin.knowledge-base', label: '管理端 · 文章管理', group: 'admin' },
|
||||
{ key: 'admin.yolo-model', label: '管理端 · YOLO 模型相关', group: 'admin' },
|
||||
|
||||
{ key: 'user.system-monitor', label: '用户端 · 系统监控', group: 'user' },
|
||||
{ key: 'user.ai-chat', label: '用户端 · AI 助手', group: 'user' },
|
||||
{ key: 'user.yolo-detection', label: '用户端 · YOLO 检测', group: 'user' },
|
||||
{ key: 'user.live-detection', label: '用户端 · 实时检测', group: 'user' },
|
||||
{ key: 'user.detection-history', label: '用户端 · 检测历史', group: 'user' },
|
||||
{ key: 'user.alert-center', label: '用户端 · 告警中心', group: 'user' },
|
||||
{ key: 'user.notice-center', label: '用户端 · 通知中心', group: 'user' },
|
||||
{ key: 'user.knowledge-center', label: '用户端 · 知识库中心', group: 'user' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 每个模块对应的裁剪配置:
|
||||
* - adminModuleKey / userModuleKey: 在路由配置文件中的 moduleKey 值
|
||||
* - adminComponentNames / userComponentNames: 在组件映射对象中的组件名(*.vue)
|
||||
* - viewFiles: 可以安全删除的页面文件(相对项目根路径)
|
||||
*/
|
||||
const PRUNE_CONFIG = {
|
||||
'admin.user-management': {
|
||||
adminModuleKey: 'admin.user-management',
|
||||
adminComponentNames: ['UserManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/UserManagement.vue'],
|
||||
},
|
||||
'admin.department-management': {
|
||||
adminModuleKey: 'admin.department-management',
|
||||
adminComponentNames: ['DepartmentManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/DepartmentManagement.vue'],
|
||||
},
|
||||
'admin.menu-management': {
|
||||
adminModuleKey: 'admin.menu-management',
|
||||
adminComponentNames: ['MenuManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/MenuManagement.vue'],
|
||||
},
|
||||
'admin.role-management': {
|
||||
adminModuleKey: 'admin.role-management',
|
||||
adminComponentNames: ['Role.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/Role.vue'],
|
||||
},
|
||||
'admin.notification-management': {
|
||||
adminModuleKey: 'admin.notification-management',
|
||||
adminComponentNames: ['NotificationManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/NotificationManagement.vue'],
|
||||
},
|
||||
'admin.log-management': {
|
||||
adminModuleKey: 'admin.log-management',
|
||||
adminComponentNames: ['LogManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/LogManagement.vue'],
|
||||
},
|
||||
'admin.knowledge-base': {
|
||||
adminModuleKey: 'admin.knowledge-base',
|
||||
adminComponentNames: ['KnowledgeBaseManagement.vue'],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: ['src/views/admin_page/KnowledgeBaseManagement.vue'],
|
||||
},
|
||||
'admin.yolo-model': {
|
||||
adminModuleKey: 'admin.yolo-model',
|
||||
adminComponentNames: [
|
||||
'ModelManagement.vue',
|
||||
'AlertLevelManagement.vue',
|
||||
'AlertProcessingCenter.vue',
|
||||
'DetectionHistoryManagement.vue',
|
||||
],
|
||||
userModuleKey: null,
|
||||
userComponentNames: [],
|
||||
viewFiles: [
|
||||
'src/views/admin_page/ModelManagement.vue',
|
||||
'src/views/admin_page/AlertLevelManagement.vue',
|
||||
'src/views/admin_page/AlertProcessingCenter.vue',
|
||||
'src/views/admin_page/DetectionHistoryManagement.vue',
|
||||
],
|
||||
},
|
||||
'user.system-monitor': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.system-monitor',
|
||||
userComponentNames: ['SystemMonitor.vue'],
|
||||
viewFiles: ['src/views/user_pages/SystemMonitor.vue'],
|
||||
},
|
||||
'user.ai-chat': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.ai-chat',
|
||||
userComponentNames: ['AiChat.vue'],
|
||||
viewFiles: ['src/views/user_pages/AiChat.vue'],
|
||||
},
|
||||
'user.yolo-detection': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.yolo-detection',
|
||||
userComponentNames: ['YoloDetection.vue'],
|
||||
viewFiles: ['src/views/user_pages/YoloDetection.vue'],
|
||||
},
|
||||
'user.live-detection': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.live-detection',
|
||||
userComponentNames: ['LiveDetection.vue'],
|
||||
viewFiles: ['src/views/user_pages/LiveDetection.vue'],
|
||||
},
|
||||
'user.detection-history': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.detection-history',
|
||||
userComponentNames: ['DetectionHistory.vue'],
|
||||
viewFiles: ['src/views/user_pages/DetectionHistory.vue'],
|
||||
},
|
||||
'user.alert-center': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.alert-center',
|
||||
userComponentNames: ['AlertCenter.vue'],
|
||||
viewFiles: ['src/views/user_pages/AlertCenter.vue'],
|
||||
},
|
||||
'user.notice-center': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.notice-center',
|
||||
userComponentNames: ['NoticeCenter.vue'],
|
||||
viewFiles: ['src/views/user_pages/NoticeCenter.vue'],
|
||||
},
|
||||
'user.knowledge-center': {
|
||||
adminModuleKey: null,
|
||||
adminComponentNames: [],
|
||||
userModuleKey: 'user.knowledge-center',
|
||||
userComponentNames: ['KnowledgeCenter.vue'],
|
||||
// 注意:这里只删除 KnowledgeCenter.vue,保留 KnowledgeDetail.vue,避免复杂路由修改
|
||||
viewFiles: ['src/views/user_pages/KnowledgeCenter.vue'],
|
||||
},
|
||||
}
|
||||
|
||||
const ADMIN_MENU_FILE = 'src/router/admin_menu.ts'
|
||||
const USER_MENU_FILE = 'src/router/user_menu_ai.ts'
|
||||
|
||||
/**
|
||||
* 简单的 CLI 交互封装
|
||||
*/
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
|
||||
function ask(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePath(relativePath) {
|
||||
return path.resolve(projectRoot, relativePath)
|
||||
}
|
||||
|
||||
function readTextFile(relativePath) {
|
||||
const full = resolvePath(relativePath)
|
||||
if (!fs.existsSync(full)) {
|
||||
return null
|
||||
}
|
||||
return fs.readFileSync(full, 'utf8')
|
||||
}
|
||||
|
||||
function writeTextFile(relativePath, content) {
|
||||
const full = resolvePath(relativePath)
|
||||
fs.writeFileSync(full, content, 'utf8')
|
||||
}
|
||||
|
||||
function commentComponentLines(content, componentNames) {
|
||||
if (!componentNames || componentNames.length === 0) return content
|
||||
const lines = content.split('\n')
|
||||
const nameSet = new Set(componentNames)
|
||||
|
||||
const updated = lines.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('//')) return line
|
||||
|
||||
for (const name of nameSet) {
|
||||
if (line.includes(`'${name}'`)) {
|
||||
return `// ${line}`
|
||||
}
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
return updated.join('\n')
|
||||
}
|
||||
|
||||
function updateModuleKey(content, originalKey) {
|
||||
if (!originalKey) return content
|
||||
const patterns = [
|
||||
`moduleKey: '${originalKey}'`,
|
||||
`moduleKey: "${originalKey}"`,
|
||||
]
|
||||
const pruned = `moduleKey: '__pruned__${originalKey}'`
|
||||
|
||||
if (content.includes(pruned)) {
|
||||
return content
|
||||
}
|
||||
|
||||
let updated = content
|
||||
let found = false
|
||||
|
||||
for (const p of patterns) {
|
||||
if (updated.includes(p)) {
|
||||
updated = updated.replace(p, pruned)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.warn(`⚠️ 未在文件中找到 moduleKey: ${originalKey}`)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
function collectViewFilesForModules(selectedKeys) {
|
||||
const files = new Set()
|
||||
for (const key of selectedKeys) {
|
||||
const cfg = PRUNE_CONFIG[key]
|
||||
if (!cfg) continue
|
||||
for (const f of cfg.viewFiles) {
|
||||
files.add(f)
|
||||
}
|
||||
}
|
||||
return Array.from(files)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('===== Hertz 模板 · 一键裁剪脚本 =====')
|
||||
console.log('说明:')
|
||||
console.log('1. 建议先在浏览器里通过“模板模式 + 模块选择页”确认要保留的模块')
|
||||
console.log('2. 然后关闭 dev 服务器,运行本脚本选择要裁剪掉的模块')
|
||||
console.log('3. 先可选择“仅屏蔽”,确认无误后,再选择“删除”彻底缩减代码体积')
|
||||
console.log('')
|
||||
|
||||
console.log('当前可裁剪模块:')
|
||||
MODULES.forEach((m, index) => {
|
||||
console.log(`${index + 1}. [${m.group}] ${m.label} (${m.key})`)
|
||||
})
|
||||
console.log('')
|
||||
|
||||
const indexAnswer = await ask('请输入要“裁剪掉”的模块序号(多个用逗号分隔,例如 2,4,7),或直接回车取消:')
|
||||
if (!indexAnswer) {
|
||||
console.log('未选择任何模块,退出。')
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
|
||||
const indexes = indexAnswer
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n) && n >= 1 && n <= MODULES.length)
|
||||
|
||||
if (indexes.length === 0) {
|
||||
console.log('未解析出有效的序号,退出。')
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
|
||||
const selectedModules = Array.from(new Set(indexes.map((i) => MODULES[i - 1])))
|
||||
console.log('\n将要裁剪的模块:')
|
||||
selectedModules.forEach((m) => {
|
||||
console.log(`- [${m.group}] ${m.label} (${m.key})`)
|
||||
})
|
||||
|
||||
console.log('\n裁剪模式:')
|
||||
console.log('1) 仅屏蔽模块:')
|
||||
console.log(' - 修改 router 配置中的 moduleKey 为 __pruned__...')
|
||||
console.log(' - 生成的菜单和路由中将完全隐藏这些模块')
|
||||
console.log(' - 不删除任何 .vue 页面文件(可随时恢复)')
|
||||
console.log('2) 删除模块:')
|
||||
console.log(' - 在 1 的基础上,额外删除对应的 .vue 页面文件')
|
||||
console.log(' - 删除操作不可逆,请确保已经提交或备份代码\n')
|
||||
|
||||
const modeAnswer = await ask('请选择裁剪模式(1 = 仅屏蔽,2 = 删除):')
|
||||
const mode = modeAnswer === '2' ? 'delete' : 'comment'
|
||||
|
||||
const viewFiles = collectViewFilesForModules(selectedModules.map((m) => m.key))
|
||||
|
||||
console.log('\n即将进行如下修改:')
|
||||
console.log('- 修改文件: src/router/admin_menu.ts(按需)')
|
||||
console.log('- 修改文件: src/router/user_menu_ai.ts(按需)')
|
||||
if (mode === 'delete') {
|
||||
console.log('- 删除页面文件:')
|
||||
viewFiles.forEach((f) => console.log(` · ${f}`))
|
||||
} else {
|
||||
console.log('- 不删除任何页面文件,仅屏蔽模块')
|
||||
}
|
||||
|
||||
const confirm = await ask('\n确认执行这些修改吗?(y/N): ')
|
||||
if (confirm.toLowerCase() !== 'y') {
|
||||
console.log('已取消操作。')
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 修改 admin_menu.ts
|
||||
let adminMenuContent = readTextFile(ADMIN_MENU_FILE)
|
||||
if (adminMenuContent) {
|
||||
const adminKeys = selectedModules.map((m) => m.key).filter((k) => PRUNE_CONFIG[k]?.adminModuleKey)
|
||||
if (adminKeys.length > 0) {
|
||||
for (const key of adminKeys) {
|
||||
const cfg = PRUNE_CONFIG[key]
|
||||
adminMenuContent = updateModuleKey(adminMenuContent, cfg.adminModuleKey)
|
||||
adminMenuContent = commentComponentLines(adminMenuContent, cfg.adminComponentNames)
|
||||
}
|
||||
writeTextFile(ADMIN_MENU_FILE, adminMenuContent)
|
||||
console.log('✅ 已更新 src/router/admin_menu.ts')
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 修改 user_menu_ai.ts
|
||||
let userMenuContent = readTextFile(USER_MENU_FILE)
|
||||
if (userMenuContent) {
|
||||
const userKeys = selectedModules.map((m) => m.key).filter((k) => PRUNE_CONFIG[k]?.userModuleKey)
|
||||
if (userKeys.length > 0) {
|
||||
for (const key of userKeys) {
|
||||
const cfg = PRUNE_CONFIG[key]
|
||||
userMenuContent = updateModuleKey(userMenuContent, cfg.userModuleKey)
|
||||
userMenuContent = commentComponentLines(userMenuContent, cfg.userComponentNames)
|
||||
}
|
||||
writeTextFile(USER_MENU_FILE, userMenuContent)
|
||||
console.log('✅ 已更新 src/router/user_menu_ai.ts')
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 删除 .vue 页面文件(仅在 delete 模式下)
|
||||
if (mode === 'delete') {
|
||||
console.log('\n开始删除页面文件...')
|
||||
for (const relative of viewFiles) {
|
||||
const full = resolvePath(relative)
|
||||
if (fs.existsSync(full)) {
|
||||
fs.rmSync(full)
|
||||
console.log(`🗑️ 已删除: ${relative}`)
|
||||
} else {
|
||||
console.log(`⚠️ 文件不存在,跳过: ${relative}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 裁剪完成。建议执行以下操作检查:')
|
||||
console.log('- 重新运行: npm run dev')
|
||||
console.log('- 在浏览器中确认菜单和路由是否符合预期')
|
||||
console.log('- 如需恢复,请使用 Git 回退或重新拷贝模板')
|
||||
|
||||
rl.close()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('执行过程中发生错误:', err)
|
||||
rl.close()
|
||||
process.exit(1)
|
||||
})
|
||||
85
hertz_server_django_ui/src/App.vue
Normal file
85
hertz_server_django_ui/src/App.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useUserStore } from './stores/hertz_user'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ConfigProvider } from 'ant-design-vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import enUS from 'ant-design-vue/es/locale/en_US'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 主题配置 - 简约现代风格
|
||||
const theme = ref({
|
||||
algorithm: 'default' as 'default' | 'dark' | 'compact',
|
||||
token: {
|
||||
colorPrimary: '#2563eb',
|
||||
colorSuccess: '#10b981',
|
||||
colorWarning: '#f59e0b',
|
||||
colorError: '#ef4444',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
})
|
||||
|
||||
// 语言配置
|
||||
const locale = ref(zhCN)
|
||||
|
||||
// 主题切换
|
||||
const toggleTheme = () => {
|
||||
const currentTheme = localStorage.getItem('theme') || 'light'
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light'
|
||||
|
||||
localStorage.setItem('theme', newTheme)
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
theme.value.algorithm = 'dark'
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
theme.value.algorithm = 'default'
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化主题
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light'
|
||||
if (savedTheme === 'dark') {
|
||||
theme.value.algorithm = 'dark'
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
theme.value.algorithm = 'default'
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
}
|
||||
})
|
||||
|
||||
const showLayout = computed(() => {
|
||||
return userStore.isLoggedIn
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<ConfigProvider :theme="theme" :locale="locale">
|
||||
<RouterView />
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
96
hertz_server_django_ui/src/api/ai.ts
Normal file
96
hertz_server_django_ui/src/api/ai.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 会话与消息类型
|
||||
export interface AIChatItem {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
latest_message?: string
|
||||
}
|
||||
|
||||
export interface AIChatDetail {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AIChatMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatListData {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
chats: AIChatItem[]
|
||||
}
|
||||
|
||||
export interface ChatDetailData {
|
||||
chat: AIChatDetail
|
||||
messages: AIChatMessage[]
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
user_message: AIChatMessage
|
||||
ai_message: AIChatMessage
|
||||
}
|
||||
|
||||
// 将后端可能返回的 chat_id 统一规范为 id
|
||||
const normalizeChatItem = (raw: any): AIChatItem => ({
|
||||
id: typeof raw?.id === 'number' ? raw.id : Number(raw?.chat_id),
|
||||
title: raw?.title,
|
||||
created_at: raw?.created_at,
|
||||
updated_at: raw?.updated_at,
|
||||
latest_message: raw?.latest_message,
|
||||
})
|
||||
|
||||
const normalizeChatDetail = (raw: any): AIChatDetail => ({
|
||||
id: typeof raw?.id === 'number' ? raw.id : Number(raw?.chat_id),
|
||||
title: raw?.title,
|
||||
created_at: raw?.created_at,
|
||||
updated_at: raw?.updated_at,
|
||||
})
|
||||
|
||||
export const aiApi = {
|
||||
listChats: (params?: { query?: string; page?: number; page_size?: number }): Promise<ApiResponse<ChatListData>> =>
|
||||
request.get('/api/ai/chats/', { params, showError: false }).then((resp: any) => {
|
||||
if (resp?.data?.chats && Array.isArray(resp.data.chats)) {
|
||||
resp.data.chats = resp.data.chats.map((c: any) => normalizeChatItem(c))
|
||||
}
|
||||
return resp as ApiResponse<ChatListData>
|
||||
}),
|
||||
|
||||
createChat: (body?: { title?: string }): Promise<ApiResponse<AIChatDetail>> =>
|
||||
request.post('/api/ai/chats/create/', body || { title: '新对话' }).then((resp: any) => {
|
||||
if (resp?.data) resp.data = normalizeChatDetail(resp.data)
|
||||
return resp as ApiResponse<AIChatDetail>
|
||||
}),
|
||||
|
||||
getChatDetail: (chatId: number): Promise<ApiResponse<ChatDetailData>> =>
|
||||
request.get(`/api/ai/chats/${chatId}/`).then((resp: any) => {
|
||||
if (resp?.data?.chat) resp.data.chat = normalizeChatDetail(resp.data.chat)
|
||||
return resp as ApiResponse<ChatDetailData>
|
||||
}),
|
||||
|
||||
updateChat: (chatId: number, body: { title: string }): Promise<ApiResponse<null>> =>
|
||||
request.put(`/api/ai/chats/${chatId}/update/`, body),
|
||||
|
||||
deleteChats: (chatIds: number[]): Promise<ApiResponse<null>> =>
|
||||
request.post('/api/ai/chats/delete/', { chat_ids: chatIds }),
|
||||
|
||||
sendMessage: (chatId: number, body: { content: string }): Promise<ApiResponse<SendMessageData>> =>
|
||||
request.post(`/api/ai/chats/${chatId}/send/`, body),
|
||||
}
|
||||
47
hertz_server_django_ui/src/api/auth.ts
Normal file
47
hertz_server_django_ui/src/api/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 注册接口数据类型
|
||||
export interface RegisterData {
|
||||
username: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
email: string
|
||||
phone: string
|
||||
real_name: string
|
||||
captcha: string
|
||||
captcha_id: string
|
||||
}
|
||||
|
||||
// 发送邮箱验证码数据类型
|
||||
export interface SendEmailCodeData {
|
||||
email: string
|
||||
code_type: string
|
||||
}
|
||||
|
||||
// 登录接口数据类型
|
||||
export interface LoginData {
|
||||
username: string
|
||||
password: string
|
||||
captcha_code: string
|
||||
captcha_key: string
|
||||
}
|
||||
|
||||
// 注册API
|
||||
export const registerUser = (data: RegisterData) => {
|
||||
return request.post('/api/auth/register/', data)
|
||||
}
|
||||
|
||||
// 登录API
|
||||
export const loginUser = (data: LoginData) => {
|
||||
return request.post('/api/auth/login/', data)
|
||||
}
|
||||
|
||||
// 发送邮箱验证码API
|
||||
export const sendEmailCode = (data: SendEmailCodeData) => {
|
||||
return request.post('/api/auth/email/code/', data)
|
||||
}
|
||||
|
||||
// 登出API
|
||||
export const logoutUser = () => {
|
||||
return request.post('/api/auth/logout/')
|
||||
}
|
||||
89
hertz_server_django_ui/src/api/captcha.ts
Normal file
89
hertz_server_django_ui/src/api/captcha.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 验证码相关接口类型定义
|
||||
export interface CaptchaResponse {
|
||||
captcha_id: string
|
||||
image_data: string // base64编码的图片
|
||||
expires_in: number // 过期时间(秒)
|
||||
}
|
||||
|
||||
export interface CaptchaRefreshResponse {
|
||||
captcha_id: string
|
||||
image_data: string // base64编码的图片
|
||||
expires_in: number // 过期时间(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
export const generateCaptcha = async (): Promise<CaptchaResponse> => {
|
||||
console.log('🚀 开始发送验证码生成请求...')
|
||||
console.log('📍 请求URL:', `${import.meta.env.VITE_API_BASE_URL}/api/captcha/generate/`)
|
||||
console.log('🌐 环境变量 VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
try {
|
||||
const response = await request.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: CaptchaResponse
|
||||
}>('/api/captcha/generate/')
|
||||
|
||||
console.log('✅ 验证码生成请求成功:', response)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('❌ 验证码生成请求失败 - 完整错误信息:')
|
||||
console.error('错误对象:', error)
|
||||
console.error('错误类型:', typeof error)
|
||||
console.error('错误消息:', error?.message)
|
||||
console.error('错误代码:', error?.code)
|
||||
console.error('错误状态:', error?.status)
|
||||
console.error('错误响应:', error?.response)
|
||||
console.error('错误请求:', error?.request)
|
||||
console.error('错误配置:', error?.config)
|
||||
|
||||
// 检查是否是网络错误
|
||||
if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
|
||||
console.error('🌐 网络连接错误 - 可能的原因:')
|
||||
console.error('1. 后端服务器未启动')
|
||||
console.error('2. API地址不正确')
|
||||
console.error('3. CORS配置问题')
|
||||
console.error('4. 防火墙阻止连接')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
export const refreshCaptcha = async (captcha_id: string): Promise<CaptchaRefreshResponse> => {
|
||||
console.log('🔄 开始发送验证码刷新请求...')
|
||||
console.log('📍 请求URL:', `${import.meta.env.VITE_API_BASE_URL}/api/captcha/refresh/`)
|
||||
console.log('📦 请求数据:', { captcha_id })
|
||||
|
||||
try {
|
||||
const response = await request.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: CaptchaRefreshResponse
|
||||
}>('/api/captcha/refresh/', {
|
||||
captcha_id
|
||||
})
|
||||
|
||||
console.log('✅ 验证码刷新请求成功:', response)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('❌ 验证码刷新请求失败 - 完整错误信息:')
|
||||
console.error('错误对象:', error)
|
||||
console.error('错误类型:', typeof error)
|
||||
console.error('错误消息:', error?.message)
|
||||
console.error('错误代码:', error?.code)
|
||||
console.error('错误状态:', error?.status)
|
||||
console.error('错误响应:', error?.response)
|
||||
console.error('错误请求:', error?.request)
|
||||
console.error('错误配置:', error?.config)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
393
hertz_server_django_ui/src/api/dashboard.ts
Normal file
393
hertz_server_django_ui/src/api/dashboard.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
import { logApi, type OperationLogListItem } from './log'
|
||||
import { systemMonitorApi, type SystemInfo, type CpuInfo, type MemoryInfo, type DiskInfo } from './system_monitor'
|
||||
import { noticeUserApi } from './notice_user'
|
||||
import { knowledgeApi } from './knowledge'
|
||||
|
||||
// 仪表盘统计数据类型定义
|
||||
export interface DashboardStats {
|
||||
totalUsers: number
|
||||
totalNotifications: number
|
||||
totalLogs: number
|
||||
totalKnowledge: number
|
||||
userGrowthRate: number
|
||||
notificationGrowthRate: number
|
||||
logGrowthRate: number
|
||||
knowledgeGrowthRate: number
|
||||
}
|
||||
|
||||
// 最近活动数据类型
|
||||
export interface RecentActivity {
|
||||
id: number
|
||||
action: string
|
||||
time: string
|
||||
user: string
|
||||
type: 'login' | 'create' | 'update' | 'system' | 'register'
|
||||
}
|
||||
|
||||
// 系统状态数据类型
|
||||
export interface SystemStatus {
|
||||
cpuUsage: number
|
||||
memoryUsage: number
|
||||
diskUsage: number
|
||||
networkStatus: 'normal' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
// 访问趋势数据类型
|
||||
export interface VisitTrend {
|
||||
date: string
|
||||
visits: number
|
||||
users: number
|
||||
}
|
||||
|
||||
// 仪表盘数据汇总类型
|
||||
export interface DashboardData {
|
||||
stats: DashboardStats
|
||||
recentActivities: RecentActivity[]
|
||||
systemStatus: SystemStatus
|
||||
visitTrends: VisitTrend[]
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 仪表盘API接口
|
||||
export const dashboardApi = {
|
||||
// 获取仪表盘统计数据
|
||||
getStats: (): Promise<ApiResponse<DashboardStats>> => {
|
||||
return request.get('/api/dashboard/stats/')
|
||||
},
|
||||
|
||||
// 获取真实统计数据
|
||||
getRealStats: async (): Promise<ApiResponse<DashboardStats>> => {
|
||||
try {
|
||||
// 并行获取各种统计数据
|
||||
const [notificationStats, logStats, knowledgeStats] = await Promise.all([
|
||||
noticeUserApi.statistics().catch(() => ({ success: false, data: { total_count: 0, unread_count: 0 } })),
|
||||
logApi.getList({ page: 1, page_size: 1 }).catch(() => ({ success: false, data: { count: 0 } })),
|
||||
knowledgeApi.getArticles({ page: 1, page_size: 1 }).catch(() => ({ success: false, data: { total: 0 } }))
|
||||
])
|
||||
|
||||
// 计算统计数据
|
||||
const totalNotifications = notificationStats.success ? (notificationStats.data.total_count || 0) : 0
|
||||
|
||||
// 处理日志数据 - 兼容多种返回结构
|
||||
let totalLogs = 0
|
||||
if (logStats.success && logStats.data) {
|
||||
const logData = logStats.data as any
|
||||
console.log('日志API响应数据:', logData)
|
||||
// 兼容DRF标准结构:{ count, next, previous, results }
|
||||
if ('count' in logData) {
|
||||
totalLogs = Number(logData.count) || 0
|
||||
} else if ('total' in logData) {
|
||||
totalLogs = Number(logData.total) || 0
|
||||
} else if ('total_count' in logData) {
|
||||
totalLogs = Number(logData.total_count) || 0
|
||||
} else if (logData.pagination && logData.pagination.total_count) {
|
||||
totalLogs = Number(logData.pagination.total_count) || 0
|
||||
}
|
||||
console.log('解析出的日志总数:', totalLogs)
|
||||
} else {
|
||||
console.log('日志API调用失败:', logStats)
|
||||
}
|
||||
|
||||
const totalKnowledge = knowledgeStats.success ? (knowledgeStats.data.total || 0) : 0
|
||||
|
||||
console.log('统计数据汇总:', { totalNotifications, totalLogs, totalKnowledge })
|
||||
|
||||
// 模拟增长率(实际项目中应该从后端获取)
|
||||
const stats: DashboardStats = {
|
||||
totalUsers: 0, // 暂时设为0,需要用户管理API
|
||||
totalNotifications,
|
||||
totalLogs,
|
||||
totalKnowledge,
|
||||
userGrowthRate: 0,
|
||||
notificationGrowthRate: Math.floor(Math.random() * 20) - 10, // 模拟 -10% 到 +10%
|
||||
logGrowthRate: Math.floor(Math.random() * 30) - 15, // 模拟 -15% 到 +15%
|
||||
knowledgeGrowthRate: Math.floor(Math.random() * 25) - 12 // 模拟 -12% 到 +13%
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: stats
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取真实统计数据失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '获取统计数据失败',
|
||||
data: {
|
||||
totalUsers: 0,
|
||||
totalNotifications: 0,
|
||||
totalLogs: 0,
|
||||
totalKnowledge: 0,
|
||||
userGrowthRate: 0,
|
||||
notificationGrowthRate: 0,
|
||||
logGrowthRate: 0,
|
||||
knowledgeGrowthRate: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取最近活动(从日志接口)
|
||||
getRecentActivities: async (limit: number = 10): Promise<ApiResponse<RecentActivity[]>> => {
|
||||
try {
|
||||
const response = await logApi.getList({ page: 1, page_size: limit })
|
||||
if (response.success && response.data) {
|
||||
// 根据实际API响应结构,数据可能在data.logs或data.results中
|
||||
const logs = (response.data as any).logs || (response.data as any).results || []
|
||||
const activities: RecentActivity[] = logs.map((log: any) => ({
|
||||
id: log.log_id || log.id,
|
||||
action: log.description || log.operation_description || `${log.action_type_display || log.operation_type} - ${log.module || log.operation_module}`,
|
||||
time: formatTimeAgo(log.created_at),
|
||||
user: log.username || log.user?.username || '未知用户',
|
||||
type: mapLogTypeToActivityType(log.action_type || log.operation_type)
|
||||
}))
|
||||
return {
|
||||
success: true,
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: activities
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '获取活动数据失败',
|
||||
data: []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取最近活动失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '获取活动数据失败',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取系统状态(从系统监控接口)
|
||||
getSystemStatus: async (): Promise<ApiResponse<SystemStatus>> => {
|
||||
try {
|
||||
const [cpuResponse, memoryResponse, disksResponse] = await Promise.all([
|
||||
systemMonitorApi.getCpu(),
|
||||
systemMonitorApi.getMemory(),
|
||||
systemMonitorApi.getDisks()
|
||||
])
|
||||
|
||||
if (cpuResponse.success && memoryResponse.success && disksResponse.success) {
|
||||
// 根据实际API响应结构映射数据
|
||||
const systemStatus: SystemStatus = {
|
||||
// CPU使用率:从 cpu_percent 字段获取
|
||||
cpuUsage: Math.round(cpuResponse.data.cpu_percent || 0),
|
||||
// 内存使用率:从 percent 字段获取
|
||||
memoryUsage: Math.round(memoryResponse.data.percent || 0),
|
||||
// 磁盘使用率:从磁盘数组的第一个磁盘的 percent 字段获取
|
||||
diskUsage: disksResponse.data.length > 0 ? Math.round(disksResponse.data[0].percent || 0) : 0,
|
||||
networkStatus: 'normal' as const
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: systemStatus
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '获取系统状态失败',
|
||||
data: {
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
diskUsage: 0,
|
||||
networkStatus: 'error' as const
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统状态失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '获取系统状态失败',
|
||||
data: {
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
diskUsage: 0,
|
||||
networkStatus: 'error' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取访问趋势
|
||||
getVisitTrends: (period: 'week' | 'month' | 'year' = 'week'): Promise<ApiResponse<VisitTrend[]>> => {
|
||||
return request.get('/api/dashboard/visit-trends/', { params: { period } })
|
||||
},
|
||||
|
||||
// 获取完整仪表盘数据
|
||||
getDashboardData: (): Promise<ApiResponse<DashboardData>> => {
|
||||
return request.get('/api/dashboard/overview/')
|
||||
},
|
||||
|
||||
// 模拟数据方法(用于开发阶段)
|
||||
getMockStats: (): Promise<DashboardStats> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
totalUsers: 1128,
|
||||
todayVisits: 893,
|
||||
totalOrders: 234,
|
||||
totalRevenue: 12560.50,
|
||||
userGrowthRate: 12,
|
||||
visitGrowthRate: 8,
|
||||
orderGrowthRate: -3,
|
||||
revenueGrowthRate: 15
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
},
|
||||
|
||||
getMockActivities: (): Promise<RecentActivity[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: 1,
|
||||
action: '用户 张三 登录了系统',
|
||||
time: '2分钟前',
|
||||
user: '张三',
|
||||
type: 'login'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
action: '管理员 李四 创建了新部门',
|
||||
time: '5分钟前',
|
||||
user: '李四',
|
||||
type: 'create'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
action: '用户 王五 修改了个人信息',
|
||||
time: '10分钟前',
|
||||
user: '王五',
|
||||
type: 'update'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
action: '系统自动备份完成',
|
||||
time: '1小时前',
|
||||
user: '系统',
|
||||
type: 'system'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
action: '新用户 赵六 注册成功',
|
||||
time: '2小时前',
|
||||
user: '赵六',
|
||||
type: 'register'
|
||||
}
|
||||
])
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
|
||||
getMockSystemStatus: (): Promise<SystemStatus> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
cpuUsage: 45,
|
||||
memoryUsage: 67,
|
||||
diskUsage: 32,
|
||||
networkStatus: 'normal'
|
||||
})
|
||||
}, 200)
|
||||
})
|
||||
},
|
||||
|
||||
getMockVisitTrends: (period: 'week' | 'month' | 'year' = 'week'): Promise<VisitTrend[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const data = {
|
||||
week: [
|
||||
{ date: '周一', visits: 120, users: 80 },
|
||||
{ date: '周二', visits: 150, users: 95 },
|
||||
{ date: '周三', visits: 180, users: 110 },
|
||||
{ date: '周四', visits: 200, users: 130 },
|
||||
{ date: '周五', visits: 250, users: 160 },
|
||||
{ date: '周六', visits: 180, users: 120 },
|
||||
{ date: '周日', visits: 160, users: 100 }
|
||||
],
|
||||
month: [
|
||||
{ date: '第1周', visits: 800, users: 500 },
|
||||
{ date: '第2周', visits: 950, users: 600 },
|
||||
{ date: '第3周', visits: 1100, users: 700 },
|
||||
{ date: '第4周', visits: 1200, users: 750 }
|
||||
],
|
||||
year: [
|
||||
{ date: '1月', visits: 3200, users: 2000 },
|
||||
{ date: '2月', visits: 3800, users: 2400 },
|
||||
{ date: '3月', visits: 4200, users: 2600 },
|
||||
{ date: '4月', visits: 3900, users: 2300 },
|
||||
{ date: '5月', visits: 4500, users: 2800 },
|
||||
{ date: '6月', visits: 5000, users: 3100 }
|
||||
]
|
||||
}
|
||||
resolve(data[period])
|
||||
}, 400)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:格式化时间为相对时间
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateString)
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return `${diffInSeconds}秒前`
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60)
|
||||
return `${minutes}分钟前`
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600)
|
||||
return `${hours}小时前`
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400)
|
||||
return `${days}天前`
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:将日志操作类型映射为活动类型
|
||||
function mapLogTypeToActivityType(operationType: string): RecentActivity['type'] {
|
||||
if (!operationType) return 'system'
|
||||
|
||||
const lowerType = operationType.toLowerCase()
|
||||
|
||||
if (lowerType.includes('login') || lowerType.includes('登录')) {
|
||||
return 'login'
|
||||
} else if (lowerType.includes('create') || lowerType.includes('创建') || lowerType.includes('add') || lowerType.includes('新增')) {
|
||||
return 'create'
|
||||
} else if (lowerType.includes('update') || lowerType.includes('修改') || lowerType.includes('edit') || lowerType.includes('更新')) {
|
||||
return 'update'
|
||||
} else if (lowerType.includes('register') || lowerType.includes('注册')) {
|
||||
return 'register'
|
||||
} else if (lowerType.includes('view') || lowerType.includes('查看') || lowerType.includes('get') || lowerType.includes('获取')) {
|
||||
return 'system'
|
||||
} else {
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
93
hertz_server_django_ui/src/api/department.ts
Normal file
93
hertz_server_django_ui/src/api/department.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 部门数据类型定义
|
||||
export interface Department {
|
||||
dept_id: number
|
||||
parent_id: number | null
|
||||
dept_name: string
|
||||
dept_code: string
|
||||
leader: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
status: number
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
children?: Department[]
|
||||
user_count?: number
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 部门列表数据类型
|
||||
export interface DepartmentListData {
|
||||
list: Department[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export type DepartmentListResponse = ApiResponse<DepartmentListData>
|
||||
|
||||
// 部门列表查询参数
|
||||
export interface DepartmentListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
status?: number
|
||||
parent_id?: number
|
||||
}
|
||||
|
||||
// 创建部门参数
|
||||
export interface CreateDepartmentParams {
|
||||
parent_id: null
|
||||
dept_name: string
|
||||
dept_code: string
|
||||
leader: string
|
||||
phone: string
|
||||
email: string
|
||||
status: number
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
// 更新部门参数
|
||||
export type UpdateDepartmentParams = Partial<CreateDepartmentParams>
|
||||
|
||||
// 部门API接口
|
||||
export const departmentApi = {
|
||||
// 获取部门列表
|
||||
getDepartmentList: (params?: DepartmentListParams): Promise<ApiResponse<Department[]>> => {
|
||||
return request.get('/api/departments/', { params })
|
||||
},
|
||||
|
||||
// 获取部门详情
|
||||
getDepartment: (id: number): Promise<ApiResponse<Department>> => {
|
||||
return request.get(`/api/departments/${id}/`)
|
||||
},
|
||||
|
||||
// 创建部门
|
||||
createDepartment: (data: CreateDepartmentParams): Promise<ApiResponse<Department>> => {
|
||||
return request.post('/api/departments/create/', data)
|
||||
},
|
||||
|
||||
// 更新部门
|
||||
updateDepartment: (id: number, data: UpdateDepartmentParams): Promise<ApiResponse<Department>> => {
|
||||
return request.put(`/api/departments/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 删除部门
|
||||
deleteDepartment: (id: number): Promise<ApiResponse<any>> => {
|
||||
return request.delete(`/api/departments/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 获取部门树
|
||||
getDepartmentTree: (): Promise<ApiResponse<Department[]>> => {
|
||||
return request.get('/api/departments/tree/')
|
||||
}
|
||||
}
|
||||
17
hertz_server_django_ui/src/api/index.ts
Normal file
17
hertz_server_django_ui/src/api/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// API 统一出口文件
|
||||
export * from './captcha'
|
||||
export * from './auth'
|
||||
export * from './user'
|
||||
export * from './department'
|
||||
export * from './menu'
|
||||
export * from './role'
|
||||
export * from './password'
|
||||
export * from './system_monitor'
|
||||
export * from './dashboard'
|
||||
|
||||
export * from './ai'
|
||||
// 这里可以继续添加其它 API 模块的导出,例如:
|
||||
// export * from './admin'
|
||||
export * from './log'
|
||||
export * from './knowledge'
|
||||
export * from './kb'
|
||||
131
hertz_server_django_ui/src/api/kb.ts
Normal file
131
hertz_server_django_ui/src/api/kb.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应结构(与后端 HertzResponse 对齐)
|
||||
export interface KbApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 知识库条目
|
||||
export interface KbItem {
|
||||
id: number
|
||||
title: string
|
||||
modality: 'text' | 'code' | 'image' | 'audio' | 'video' | string
|
||||
source_type: 'text' | 'file' | 'url' | string
|
||||
chunk_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
created_chunk_count?: number
|
||||
// 允许后端扩展字段
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface KbItemListParams {
|
||||
query?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface KbItemListData {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
list: KbItem[]
|
||||
}
|
||||
|
||||
// 语义搜索
|
||||
export interface KbSearchParams {
|
||||
q: string
|
||||
k?: number
|
||||
}
|
||||
|
||||
// 问答(RAG)
|
||||
export interface KbQaPayload {
|
||||
question: string
|
||||
k?: number
|
||||
}
|
||||
|
||||
export interface KbQaData {
|
||||
answer: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 图谱查询参数(实体 / 关系)
|
||||
export interface KbGraphListParams {
|
||||
query?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
// 关系检索可选参数
|
||||
source?: number
|
||||
target?: number
|
||||
relation_type?: string
|
||||
}
|
||||
|
||||
export const kbApi = {
|
||||
// 知识库条目:列表
|
||||
listItems(params?: KbItemListParams): Promise<KbApiResponse<KbItemListData>> {
|
||||
return request.get('/api/kb/items/list/', { params })
|
||||
},
|
||||
|
||||
// 语义搜索
|
||||
search(params: KbSearchParams): Promise<KbApiResponse<any>> {
|
||||
return request.get('/api/kb/search/', { params })
|
||||
},
|
||||
|
||||
// 问答(RAG)
|
||||
qa(payload: KbQaPayload): Promise<KbApiResponse<KbQaData>> {
|
||||
return request.post('/api/kb/qa/', payload)
|
||||
},
|
||||
|
||||
// 图谱:实体列表
|
||||
listEntities(params?: KbGraphListParams): Promise<KbApiResponse<any>> {
|
||||
return request.get('/api/kb/graph/entities/', { params })
|
||||
},
|
||||
|
||||
// 图谱:关系列表
|
||||
listRelations(params?: KbGraphListParams): Promise<KbApiResponse<any>> {
|
||||
return request.get('/api/kb/graph/relations/', { params })
|
||||
},
|
||||
|
||||
// 知识库条目:创建(JSON 文本)
|
||||
createItemJson(payload: { title: string; modality?: string; source_type?: string; content?: string; metadata?: any }): Promise<KbApiResponse<KbItem>> {
|
||||
return request.post('/api/kb/items/create/', payload)
|
||||
},
|
||||
|
||||
// 知识库条目:创建(文件上传)
|
||||
createItemFile(formData: FormData): Promise<KbApiResponse<KbItem>> {
|
||||
return request.post('/api/kb/items/create/', formData)
|
||||
},
|
||||
|
||||
// 图谱:创建实体
|
||||
createEntity(payload: { name: string; type: string; properties?: any }): Promise<KbApiResponse<any>> {
|
||||
return request.post('/api/kb/graph/entities/', payload)
|
||||
},
|
||||
|
||||
// 图谱:更新实体
|
||||
updateEntity(id: number, payload: { name?: string; type?: string; properties?: any }): Promise<KbApiResponse<any>> {
|
||||
return request.put(`/api/kb/graph/entities/${id}/`, payload)
|
||||
},
|
||||
|
||||
// 图谱:删除实体
|
||||
deleteEntity(id: number): Promise<KbApiResponse<null>> {
|
||||
return request.delete(`/api/kb/graph/entities/${id}/`)
|
||||
},
|
||||
|
||||
// 图谱:创建关系
|
||||
createRelation(payload: { source: number; target: number; relation_type: string; properties?: any; source_chunk?: number }): Promise<KbApiResponse<any>> {
|
||||
return request.post('/api/kb/graph/relations/', payload)
|
||||
},
|
||||
|
||||
// 图谱:删除关系
|
||||
deleteRelation(id: number): Promise<KbApiResponse<null>> {
|
||||
return request.delete(`/api/kb/graph/relations/${id}/`)
|
||||
},
|
||||
|
||||
// 图谱:自动抽取实体与关系
|
||||
extractGraph(payload: { text?: string; item_id?: number }): Promise<KbApiResponse<{ entities: number; relations: number }>> {
|
||||
return request.post('/api/kb/graph/extract/', payload)
|
||||
},
|
||||
}
|
||||
173
hertz_server_django_ui/src/api/knowledge.ts
Normal file
173
hertz_server_django_ui/src/api/knowledge.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应结构
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分类类型
|
||||
export interface KnowledgeCategory {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
parent?: number | null
|
||||
parent_name?: string | null
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
children_count?: number
|
||||
articles_count?: number
|
||||
full_path?: string
|
||||
children?: KnowledgeCategory[]
|
||||
}
|
||||
|
||||
export interface CategoryListData {
|
||||
list: KnowledgeCategory[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CategoryListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
name?: string
|
||||
parent_id?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// 文章类型
|
||||
export interface KnowledgeArticleListItem {
|
||||
id: number
|
||||
title: string
|
||||
summary?: string | null
|
||||
image?: string | null
|
||||
category_name: string
|
||||
author_name: string
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
status_display: string
|
||||
view_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
published_at?: string | null
|
||||
}
|
||||
|
||||
export interface KnowledgeArticleDetail extends KnowledgeArticleListItem {
|
||||
content: string
|
||||
category: number
|
||||
author: number
|
||||
tags?: string
|
||||
tags_list?: string[]
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface ArticleListData {
|
||||
list: KnowledgeArticleListItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface ArticleListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
title?: string
|
||||
category_id?: number
|
||||
author_id?: number
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
tags?: string
|
||||
}
|
||||
|
||||
export interface CreateArticlePayload {
|
||||
title: string
|
||||
content: string
|
||||
summary?: string
|
||||
image?: string
|
||||
category: number
|
||||
status?: 'draft' | 'published'
|
||||
tags?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
export interface UpdateArticlePayload {
|
||||
title?: string
|
||||
content?: string
|
||||
summary?: string
|
||||
image?: string
|
||||
category?: number
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
tags?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
// 知识库 API
|
||||
export const knowledgeApi = {
|
||||
// 分类:列表
|
||||
getCategories: (params?: CategoryListParams): Promise<ApiResponse<CategoryListData>> => {
|
||||
return request.get('/api/wiki/categories/', { params })
|
||||
},
|
||||
|
||||
// 分类:树形
|
||||
getCategoryTree: (): Promise<ApiResponse<KnowledgeCategory[]>> => {
|
||||
return request.get('/api/wiki/categories/tree/')
|
||||
},
|
||||
|
||||
// 分类:详情
|
||||
getCategory: (id: number): Promise<ApiResponse<KnowledgeCategory>> => {
|
||||
return request.get(`/api/wiki/categories/${id}/`)
|
||||
},
|
||||
|
||||
// 分类:创建
|
||||
createCategory: (data: Partial<KnowledgeCategory>): Promise<ApiResponse<KnowledgeCategory>> => {
|
||||
return request.post('/api/wiki/categories/create/', data)
|
||||
},
|
||||
|
||||
// 分类:更新
|
||||
updateCategory: (id: number, data: Partial<KnowledgeCategory>): Promise<ApiResponse<KnowledgeCategory>> => {
|
||||
return request.put(`/api/wiki/categories/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 分类:删除
|
||||
deleteCategory: (id: number): Promise<ApiResponse<null>> => {
|
||||
return request.delete(`/api/wiki/categories/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 文章:列表
|
||||
getArticles: (params?: ArticleListParams): Promise<ApiResponse<ArticleListData>> => {
|
||||
return request.get('/api/wiki/articles/', { params })
|
||||
},
|
||||
|
||||
// 文章:详情
|
||||
getArticle: (id: number): Promise<ApiResponse<KnowledgeArticleDetail>> => {
|
||||
return request.get(`/api/wiki/articles/${id}/`)
|
||||
},
|
||||
|
||||
// 文章:创建
|
||||
createArticle: (data: CreateArticlePayload): Promise<ApiResponse<KnowledgeArticleDetail>> => {
|
||||
return request.post('/api/wiki/articles/create/', data)
|
||||
},
|
||||
|
||||
// 文章:更新
|
||||
updateArticle: (id: number, data: UpdateArticlePayload): Promise<ApiResponse<KnowledgeArticleDetail>> => {
|
||||
return request.put(`/api/wiki/articles/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 文章:删除
|
||||
deleteArticle: (id: number): Promise<ApiResponse<null>> => {
|
||||
return request.delete(`/api/wiki/articles/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 文章:发布
|
||||
publishArticle: (id: number): Promise<ApiResponse<null>> => {
|
||||
return request.post(`/api/wiki/articles/${id}/publish/`)
|
||||
},
|
||||
|
||||
// 文章:归档
|
||||
archiveArticle: (id: number): Promise<ApiResponse<null>> => {
|
||||
return request.post(`/api/wiki/articles/${id}/archive/`)
|
||||
},
|
||||
}
|
||||
110
hertz_server_django_ui/src/api/log.ts
Normal file
110
hertz_server_django_ui/src/api/log.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用 API 响应结构
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 列表查询参数
|
||||
export interface LogListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
user_id?: number
|
||||
operation_type?: string
|
||||
operation_module?: string
|
||||
start_date?: string // YYYY-MM-DD
|
||||
end_date?: string // YYYY-MM-DD
|
||||
ip_address?: string
|
||||
status?: number
|
||||
// 新增:按请求方法与路径、关键字筛选(与后端保持可选兼容)
|
||||
request_method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string
|
||||
request_path?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 列表项(精简字段)
|
||||
export interface OperationLogItem {
|
||||
id: number
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
} | null
|
||||
operation_type: string
|
||||
// 展示字段
|
||||
action_type_display?: string
|
||||
operation_module: string
|
||||
operation_description?: string
|
||||
target_model?: string
|
||||
target_object_id?: string
|
||||
ip_address?: string
|
||||
request_method: string
|
||||
request_path: string
|
||||
response_status: number
|
||||
// 结果与状态展示
|
||||
status_display?: string
|
||||
is_success?: boolean
|
||||
execution_time?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 列表响应 data 结构
|
||||
export interface LogListData {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: OperationLogItem[]
|
||||
}
|
||||
|
||||
export type LogListResponse = ApiResponse<LogListData>
|
||||
|
||||
// 详情数据(完整字段)
|
||||
export interface OperationLogDetail {
|
||||
id: number
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
} | null
|
||||
operation_type: string
|
||||
action_type_display?: string
|
||||
operation_module: string
|
||||
operation_description: string
|
||||
target_model?: string
|
||||
target_object_id?: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
request_method: string
|
||||
request_path: string
|
||||
request_data?: Record<string, any>
|
||||
response_status: number
|
||||
status_display?: string
|
||||
is_success?: boolean
|
||||
response_data?: Record<string, any>
|
||||
execution_time?: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type LogDetailResponse = ApiResponse<OperationLogDetail>
|
||||
|
||||
export const logApi = {
|
||||
// 获取操作日志列表
|
||||
getList: (params: LogListParams, options?: { signal?: AbortSignal }): Promise<LogListResponse> => {
|
||||
// 关闭统一错误弹窗,由页面自行处理
|
||||
return request.get('/api/log/list/', { params, showError: false, signal: options?.signal })
|
||||
},
|
||||
|
||||
// 获取操作日志详情
|
||||
getDetail: (logId: number): Promise<LogDetailResponse> => {
|
||||
return request.get(`/api/log/detail/${logId}/`)
|
||||
},
|
||||
|
||||
// 兼容查询参数方式的详情(部分后端实现为 /api/log/detail/?id=xx 或 ?log_id=xx)
|
||||
getDetailByQuery: (logId: number): Promise<LogDetailResponse> => {
|
||||
return request.get('/api/log/detail/', { params: { id: logId, log_id: logId } })
|
||||
},
|
||||
}
|
||||
361
hertz_server_django_ui/src/api/menu.ts
Normal file
361
hertz_server_django_ui/src/api/menu.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 后端返回的原始菜单数据格式
|
||||
export interface RawMenu {
|
||||
menu_id: number
|
||||
menu_name: string
|
||||
menu_code: string
|
||||
menu_type: number // 后端返回数字:1=菜单, 2=按钮, 3=接口
|
||||
parent_id?: number | null
|
||||
path?: string
|
||||
component?: string | null
|
||||
icon?: string
|
||||
permission?: string
|
||||
sort_order?: number
|
||||
description?: string
|
||||
status?: number
|
||||
is_external?: boolean
|
||||
is_cache?: boolean
|
||||
is_visible?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
children?: RawMenu[]
|
||||
}
|
||||
|
||||
// 前端使用的菜单接口类型定义
|
||||
export interface Menu {
|
||||
menu_id: number
|
||||
menu_name: string
|
||||
menu_code: string
|
||||
menu_type: number // 1=菜单, 2=按钮, 3=接口
|
||||
parent_id?: number
|
||||
path?: string
|
||||
component?: string
|
||||
icon?: string
|
||||
permission?: string
|
||||
sort_order?: number
|
||||
status?: number
|
||||
is_external?: boolean
|
||||
is_cache?: boolean
|
||||
is_visible?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
children?: Menu[]
|
||||
}
|
||||
|
||||
// API响应基础结构
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 菜单列表数据结构
|
||||
export interface MenuListData {
|
||||
list: Menu[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 菜单列表响应类型
|
||||
export type MenuListResponse = ApiResponse<MenuListData>
|
||||
|
||||
// 菜单列表查询参数
|
||||
export interface MenuListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
status?: number
|
||||
menu_type?: string
|
||||
parent_id?: number
|
||||
}
|
||||
|
||||
// 创建菜单参数
|
||||
export interface CreateMenuParams {
|
||||
menu_name: string
|
||||
menu_code: string
|
||||
menu_type: number // 1=菜单, 2=按钮, 3=接口
|
||||
parent_id?: number
|
||||
path?: string
|
||||
component?: string
|
||||
icon?: string
|
||||
permission?: string
|
||||
sort_order?: number
|
||||
status?: number
|
||||
is_external?: boolean
|
||||
is_cache?: boolean
|
||||
is_visible?: boolean
|
||||
}
|
||||
|
||||
// 更新菜单参数
|
||||
export type UpdateMenuParams = Partial<CreateMenuParams>
|
||||
|
||||
// 菜单树响应类型
|
||||
export type MenuTreeResponse = ApiResponse<Menu[]>
|
||||
|
||||
// 数据转换工具函数
|
||||
const convertMenuType = (type: number): 'menu' | 'button' | 'api' => {
|
||||
switch (type) {
|
||||
case 1: return 'menu'
|
||||
case 2: return 'button'
|
||||
case 3: return 'api'
|
||||
default: return 'menu'
|
||||
}
|
||||
}
|
||||
|
||||
// 解码Unicode字符串
|
||||
const decodeUnicode = (str: string): string => {
|
||||
try {
|
||||
return str.replace(/\\u[\dA-F]{4}/gi, (match) => {
|
||||
return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16))
|
||||
})
|
||||
} catch (error) {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
// 转换原始菜单数据为前端格式
|
||||
const transformRawMenu = (rawMenu: RawMenu): Menu => {
|
||||
// 确保status字段被正确转换
|
||||
let statusValue: number
|
||||
if (rawMenu.status === undefined || rawMenu.status === null) {
|
||||
// 如果status缺失,默认为启用(1)
|
||||
statusValue = 1
|
||||
} else {
|
||||
// 如果有值,转换为数字
|
||||
if (typeof rawMenu.status === 'string') {
|
||||
const parsed = parseInt(rawMenu.status, 10)
|
||||
statusValue = isNaN(parsed) ? 1 : parsed
|
||||
} else {
|
||||
statusValue = Number(rawMenu.status)
|
||||
// 如果转换失败,默认为启用
|
||||
if (isNaN(statusValue)) {
|
||||
statusValue = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menu_id: rawMenu.menu_id,
|
||||
menu_name: decodeUnicode(rawMenu.menu_name),
|
||||
menu_code: rawMenu.menu_code,
|
||||
menu_type: rawMenu.menu_type,
|
||||
parent_id: rawMenu.parent_id || undefined,
|
||||
path: rawMenu.path,
|
||||
component: rawMenu.component,
|
||||
icon: rawMenu.icon,
|
||||
permission: rawMenu.permission,
|
||||
sort_order: rawMenu.sort_order,
|
||||
status: statusValue, // 使用转换后的值
|
||||
is_external: rawMenu.is_external,
|
||||
is_cache: rawMenu.is_cache,
|
||||
is_visible: rawMenu.is_visible,
|
||||
created_at: rawMenu.created_at,
|
||||
updated_at: rawMenu.updated_at,
|
||||
children: rawMenu.children ? rawMenu.children.map(transformRawMenu) : []
|
||||
}
|
||||
}
|
||||
|
||||
// 将菜单数据数组转换为列表格式
|
||||
const transformToMenuList = (rawMenus: RawMenu[]): MenuListData => {
|
||||
const transformedMenus = rawMenus.map(transformRawMenu)
|
||||
|
||||
// 递归收集所有菜单项
|
||||
const collectAllMenus = (menu: Menu): Menu[] => {
|
||||
const result = [menu]
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children.forEach(child => {
|
||||
result.push(...collectAllMenus(child))
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 收集所有菜单项
|
||||
const allMenus: Menu[] = []
|
||||
transformedMenus.forEach(menu => {
|
||||
allMenus.push(...collectAllMenus(menu))
|
||||
})
|
||||
|
||||
return {
|
||||
list: allMenus,
|
||||
total: allMenus.length,
|
||||
page: 1,
|
||||
page_size: allMenus.length
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单树结构
|
||||
const buildMenuTree = (rawMenus: RawMenu[]): Menu[] => {
|
||||
const transformedMenus = rawMenus.map(transformRawMenu)
|
||||
|
||||
// 创建菜单映射
|
||||
const menuMap = new Map<number, Menu>()
|
||||
transformedMenus.forEach(menu => {
|
||||
menuMap.set(menu.menu_id, { ...menu, children: [] })
|
||||
})
|
||||
|
||||
// 构建树结构
|
||||
const rootMenus: Menu[] = []
|
||||
transformedMenus.forEach(menu => {
|
||||
const menuItem = menuMap.get(menu.menu_id)!
|
||||
|
||||
if (menu.parent_id && menuMap.has(menu.parent_id)) {
|
||||
const parent = menuMap.get(menu.parent_id)!
|
||||
if (!parent.children) parent.children = []
|
||||
parent.children.push(menuItem)
|
||||
} else {
|
||||
rootMenus.push(menuItem)
|
||||
}
|
||||
})
|
||||
|
||||
return rootMenus
|
||||
}
|
||||
|
||||
// 菜单API
|
||||
export const menuApi = {
|
||||
// 获取菜单列表
|
||||
getMenuList: async (params?: MenuListParams): Promise<MenuListResponse> => {
|
||||
try {
|
||||
const response = await request.get<ApiResponse<RawMenu[]>>('/api/menus/', { params })
|
||||
|
||||
if (response.success && response.data && Array.isArray(response.data)) {
|
||||
const menuListData = transformToMenuList(response.data)
|
||||
return {
|
||||
success: true,
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: menuListData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: response.code || 500,
|
||||
message: response.message || '获取菜单数据失败',
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取菜单列表失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '网络请求失败',
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取菜单树
|
||||
getMenuTree: async (): Promise<MenuTreeResponse> => {
|
||||
try {
|
||||
const response = await request.get<ApiResponse<RawMenu[]>>('/api/menus/tree/')
|
||||
|
||||
if (response.success && response.data && Array.isArray(response.data)) {
|
||||
// 调试:检查原始数据中的status值
|
||||
if (response.data.length > 0) {
|
||||
console.log('🔍 原始菜单数据status检查(前5条):', response.data.slice(0, 5).map((m: RawMenu) => ({
|
||||
menu_name: m.menu_name,
|
||||
menu_id: m.menu_id,
|
||||
status: m.status,
|
||||
statusType: typeof m.status
|
||||
})))
|
||||
}
|
||||
|
||||
// 后端已经返回树形结构,直接转换数据格式即可
|
||||
const transformedData = response.data.map(transformRawMenu)
|
||||
|
||||
// 调试:检查转换后的status值
|
||||
if (transformedData.length > 0) {
|
||||
console.log('🔍 转换后菜单数据status检查(前5条):', transformedData.slice(0, 5).map((m: Menu) => ({
|
||||
menu_name: m.menu_name,
|
||||
menu_id: m.menu_id,
|
||||
status: m.status,
|
||||
statusType: typeof m.status
|
||||
})))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: transformedData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
code: response.code || 500,
|
||||
message: response.message || '获取菜单树失败',
|
||||
data: []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取菜单树失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '网络请求失败',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 获取单个菜单
|
||||
getMenu: async (id: number): Promise<ApiResponse<Menu>> => {
|
||||
try {
|
||||
const response = await request.get<ApiResponse<RawMenu>>(`/api/menus/${id}/`)
|
||||
|
||||
if (response.success && response.data) {
|
||||
const transformedMenu = transformRawMenu(response.data)
|
||||
return {
|
||||
success: true,
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
data: transformedMenu
|
||||
}
|
||||
}
|
||||
|
||||
return response as ApiResponse<Menu>
|
||||
} catch (error) {
|
||||
console.error('获取菜单详情失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
code: 500,
|
||||
message: '网络请求失败',
|
||||
data: {} as Menu
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 创建菜单
|
||||
createMenu: (data: CreateMenuParams): Promise<ApiResponse<Menu>> => {
|
||||
return request.post('/api/menus/create/', data)
|
||||
},
|
||||
|
||||
// 更新菜单
|
||||
updateMenu: (id: number, data: UpdateMenuParams): Promise<ApiResponse<Menu>> => {
|
||||
return request.put(`/api/menus/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 删除菜单
|
||||
deleteMenu: (id: number): Promise<ApiResponse<any>> => {
|
||||
return request.delete(`/api/menus/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 批量删除菜单
|
||||
batchDeleteMenus: (ids: number[]): Promise<ApiResponse<any>> => {
|
||||
return request.post('/api/menus/batch-delete/', { menu_ids: ids })
|
||||
}
|
||||
}
|
||||
87
hertz_server_django_ui/src/api/notice_user.ts
Normal file
87
hertz_server_django_ui/src/api/notice_user.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 用户端通知模块 API 类型定义
|
||||
export interface UserNoticeListItem {
|
||||
notice: number
|
||||
title: string
|
||||
notice_type_display: string
|
||||
priority_display: string
|
||||
is_top: boolean
|
||||
publish_time: string
|
||||
is_read: boolean
|
||||
read_time: string | null
|
||||
is_starred: boolean
|
||||
starred_time: string | null
|
||||
is_expired: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserNoticeListData {
|
||||
notices: UserNoticeListItem[]
|
||||
pagination: {
|
||||
current_page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
total_count: number
|
||||
has_next: boolean
|
||||
has_previous: boolean
|
||||
}
|
||||
statistics: {
|
||||
total_count: number
|
||||
unread_count: number
|
||||
starred_count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface UserNoticeDetailData {
|
||||
notice: number
|
||||
title: string
|
||||
content: string
|
||||
notice_type_display: string
|
||||
priority_display: string
|
||||
attachment_url: string | null
|
||||
publish_time: string
|
||||
expire_time: string
|
||||
is_top: boolean
|
||||
is_expired: boolean
|
||||
publisher_name: string | null
|
||||
is_read: boolean
|
||||
read_time: string
|
||||
is_starred: boolean
|
||||
starred_time: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const noticeUserApi = {
|
||||
// 查看通知列表
|
||||
list: (params?: { page?: number; page_size?: number }): Promise<ApiResponse<UserNoticeListData>> =>
|
||||
request.get('/api/notice/user/list/', { params }),
|
||||
|
||||
// 查看通知详情
|
||||
detail: (notice_id: number | string): Promise<ApiResponse<UserNoticeDetailData>> =>
|
||||
request.get(`/api/notice/user/detail/${notice_id}/`),
|
||||
|
||||
// 标记通知已读
|
||||
markRead: (notice_id: number | string): Promise<ApiResponse<null>> =>
|
||||
request.post('/api/notice/user/mark-read/', { notice_id }),
|
||||
|
||||
// 批量标记通知已读
|
||||
batchMarkRead: (notice_ids: Array<number | string>): Promise<ApiResponse<{ updated_count: number }>> =>
|
||||
request.post('/api/notice/user/batch-mark-read/', { notice_ids }),
|
||||
|
||||
// 用户获取通知统计
|
||||
statistics: (): Promise<ApiResponse<{ total_count: number; unread_count: number; read_count: number; starred_count: number; type_statistics?: Record<string, number>; priority_statistics?: Record<string, number> }>> =>
|
||||
request.get('/api/notice/user/statistics/'),
|
||||
|
||||
// 收藏/取消收藏通知
|
||||
toggleStar: (notice_id: number | string, is_starred: boolean): Promise<ApiResponse<null>> =>
|
||||
request.post('/api/notice/user/toggle-star/', { notice_id, is_starred }),
|
||||
}
|
||||
31
hertz_server_django_ui/src/api/password.ts
Normal file
31
hertz_server_django_ui/src/api/password.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 修改密码接口参数
|
||||
export interface ChangePasswordParams {
|
||||
old_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
// 重置密码接口参数
|
||||
export interface ResetPasswordParams {
|
||||
email: string
|
||||
email_code: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export const changePassword = (params: ChangePasswordParams) => {
|
||||
return request.post('/api/auth/password/change/', params)
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = (params: ResetPasswordParams) => {
|
||||
return request.post('/api/auth/password/reset/', params)
|
||||
}
|
||||
|
||||
// 发送重置密码邮箱验证码
|
||||
export const sendResetPasswordCode = (email: string) => {
|
||||
return request.post('/api/auth/password/reset/code/', { email })
|
||||
}
|
||||
130
hertz_server_django_ui/src/api/role.ts
Normal file
130
hertz_server_django_ui/src/api/role.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 权限接口类型定义
|
||||
export interface Permission {
|
||||
permission_id: number
|
||||
permission_name: string
|
||||
permission_code: string
|
||||
permission_type: 'menu' | 'button' | 'api'
|
||||
parent_id?: number
|
||||
path?: string
|
||||
icon?: string
|
||||
sort_order?: number
|
||||
description?: string
|
||||
status?: number
|
||||
children?: Permission[]
|
||||
}
|
||||
|
||||
// 角色接口类型定义
|
||||
export interface Role {
|
||||
role_id: number
|
||||
role_name: string
|
||||
role_code: string
|
||||
description?: string
|
||||
status?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
permissions?: Permission[]
|
||||
}
|
||||
|
||||
// API响应基础结构
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 角色列表数据结构
|
||||
export interface RoleListData {
|
||||
list: Role[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 角色列表响应类型
|
||||
export type RoleListResponse = ApiResponse<RoleListData>
|
||||
|
||||
// 角色列表查询参数
|
||||
export interface RoleListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 创建角色参数
|
||||
export interface CreateRoleParams {
|
||||
role_name: string
|
||||
role_code: string
|
||||
description?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 更新角色参数
|
||||
export type UpdateRoleParams = Partial<CreateRoleParams>
|
||||
|
||||
// 角色权限分配参数
|
||||
export interface AssignRolePermissionsParams {
|
||||
role_id: number
|
||||
menu_ids: number[]
|
||||
user_type?: number
|
||||
department_id?: number
|
||||
}
|
||||
|
||||
// 权限列表响应类型
|
||||
export type PermissionListResponse = ApiResponse<Permission[]>
|
||||
|
||||
// 角色API
|
||||
export const roleApi = {
|
||||
// 获取角色列表
|
||||
getRoleList: (params?: RoleListParams): Promise<RoleListResponse> => {
|
||||
return request.get('/api/roles/', { params })
|
||||
},
|
||||
|
||||
// 获取单个角色
|
||||
getRole: (id: number): Promise<ApiResponse<Role>> => {
|
||||
return request.get(`/api/roles/${id}/`)
|
||||
},
|
||||
|
||||
// 创建角色
|
||||
createRole: (data: CreateRoleParams): Promise<ApiResponse<Role>> => {
|
||||
return request.post('/api/roles/create/', data)
|
||||
},
|
||||
|
||||
// 更新角色
|
||||
updateRole: (id: number, data: UpdateRoleParams): Promise<ApiResponse<Role>> => {
|
||||
return request.put(`/api/roles/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 删除角色
|
||||
deleteRole: (id: number): Promise<ApiResponse<any>> => {
|
||||
return request.delete(`/api/roles/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 批量删除角色
|
||||
batchDeleteRoles: (ids: number[]): Promise<ApiResponse<any>> => {
|
||||
return request.post('/api/roles/batch-delete/', { role_ids: ids })
|
||||
},
|
||||
|
||||
// 获取角色权限
|
||||
getRolePermissions: (id: number): Promise<ApiResponse<Permission[]>> => {
|
||||
return request.get(`/api/roles/${id}/menus/`)
|
||||
},
|
||||
|
||||
// 分配角色权限
|
||||
assignRolePermissions: (data: AssignRolePermissionsParams): Promise<ApiResponse<any>> => {
|
||||
return request.post(`/api/roles/assign-menus/`, data)
|
||||
},
|
||||
|
||||
// 获取所有权限列表
|
||||
getPermissionList: (): Promise<PermissionListResponse> => {
|
||||
return request.get('/api/menus/')
|
||||
},
|
||||
|
||||
// 获取权限树
|
||||
getPermissionTree: (): Promise<PermissionListResponse> => {
|
||||
return request.get('/api/menus/tree/')
|
||||
}
|
||||
}
|
||||
114
hertz_server_django_ui/src/api/system_monitor.ts
Normal file
114
hertz_server_django_ui/src/api/system_monitor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 1. 系统信息
|
||||
export interface SystemInfo {
|
||||
hostname: string
|
||||
platform: string
|
||||
architecture: string
|
||||
boot_time: string
|
||||
uptime: string
|
||||
}
|
||||
|
||||
// 2. CPU 信息
|
||||
export interface CpuInfo {
|
||||
cpu_count: number
|
||||
cpu_percent: number
|
||||
cpu_freq: {
|
||||
current: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
load_avg: number[]
|
||||
}
|
||||
|
||||
// 3. 内存信息
|
||||
export interface MemoryInfo {
|
||||
total: number
|
||||
available: number
|
||||
used: number
|
||||
percent: number
|
||||
free: number
|
||||
}
|
||||
|
||||
// 4. 磁盘信息
|
||||
export interface DiskInfo {
|
||||
device: string
|
||||
mountpoint: string
|
||||
fstype: string
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
// 5. 网络信息
|
||||
export interface NetworkInfo {
|
||||
interface: string
|
||||
bytes_sent: number
|
||||
bytes_recv: number
|
||||
packets_sent: number
|
||||
packets_recv: number
|
||||
}
|
||||
|
||||
// 6. 进程信息
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
status: string
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
memory_info: {
|
||||
rss: number
|
||||
vms: number
|
||||
}
|
||||
create_time: string
|
||||
cmdline: string[]
|
||||
}
|
||||
|
||||
// 7. GPU 信息
|
||||
export interface GpuInfoItem {
|
||||
id: number
|
||||
name: string
|
||||
load: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_util: number
|
||||
temperature: number
|
||||
}
|
||||
|
||||
export interface GpuInfoResponse {
|
||||
gpu_available: boolean
|
||||
gpu_info?: GpuInfoItem[]
|
||||
message?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// 8. 综合监测信息
|
||||
export interface MonitorData {
|
||||
system: SystemInfo
|
||||
cpu: CpuInfo
|
||||
memory: MemoryInfo
|
||||
disks: DiskInfo[]
|
||||
network: NetworkInfo[]
|
||||
processes: ProcessInfo[]
|
||||
gpus: Array<{ gpu_available: boolean; message?: string; timestamp: string }>
|
||||
}
|
||||
|
||||
export const systemMonitorApi = {
|
||||
getSystem: (): Promise<ApiResponse<SystemInfo>> => request.get('/api/system/system/'),
|
||||
getCpu: (): Promise<ApiResponse<CpuInfo>> => request.get('/api/system/cpu/'),
|
||||
getMemory: (): Promise<ApiResponse<MemoryInfo>> => request.get('/api/system/memory/'),
|
||||
getDisks: (): Promise<ApiResponse<DiskInfo[]>> => request.get('/api/system/disks/'),
|
||||
getNetwork: (): Promise<ApiResponse<NetworkInfo[]>> => request.get('/api/system/network/'),
|
||||
getProcesses: (): Promise<ApiResponse<ProcessInfo[]>> => request.get('/api/system/processes/'),
|
||||
getGpu: (): Promise<ApiResponse<GpuInfoResponse>> => request.get('/api/system/gpu/'),
|
||||
getMonitor: (): Promise<ApiResponse<MonitorData>> => request.get('/api/system/monitor/'),
|
||||
}
|
||||
121
hertz_server_django_ui/src/api/user.ts
Normal file
121
hertz_server_django_ui/src/api/user.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 角色接口类型定义
|
||||
export interface Role {
|
||||
role_id: number
|
||||
role_name: string
|
||||
role_code: string
|
||||
role_ids?: string
|
||||
}
|
||||
|
||||
// 用户接口类型定义(匹配后端实际数据结构)
|
||||
export interface User {
|
||||
user_id: number
|
||||
username: string
|
||||
email: string
|
||||
phone?: string
|
||||
real_name?: string
|
||||
avatar?: string
|
||||
gender: number
|
||||
birthday?: string
|
||||
department_id?: number
|
||||
status: number
|
||||
last_login_time?: string
|
||||
last_login_ip?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
// API响应基础结构
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 用户列表数据结构
|
||||
export interface UserListData {
|
||||
list: User[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// 用户列表响应类型
|
||||
export type UserListResponse = ApiResponse<UserListData>
|
||||
|
||||
// 用户列表查询参数
|
||||
export interface UserListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
status?: number
|
||||
role_ids?: string
|
||||
}
|
||||
|
||||
// 分配角色参数
|
||||
export interface AssignRolesParams {
|
||||
user_id: number
|
||||
role_ids: number[] // 角色ID数组
|
||||
}
|
||||
|
||||
// 用户API
|
||||
export const userApi = {
|
||||
// 获取用户列表
|
||||
getUserList: (params?: UserListParams): Promise<UserListResponse> => {
|
||||
return request.get('/api/users/', { params })
|
||||
},
|
||||
|
||||
// 获取单个用户
|
||||
getUser: (id: number): Promise<ApiResponse<User>> => {
|
||||
return request.get(`/api/users/${id}/`)
|
||||
},
|
||||
|
||||
// 创建用户
|
||||
createUser: (data: Partial<User>): Promise<ApiResponse<User>> => {
|
||||
return request.post('/api/users/create/', data)
|
||||
},
|
||||
|
||||
// 更新用户
|
||||
updateUser: (id: number, data: Partial<User>): Promise<ApiResponse<User>> => {
|
||||
return request.put(`/api/users/${id}/update/`, data)
|
||||
},
|
||||
|
||||
// 删除用户
|
||||
deleteUser: (id: number): Promise<ApiResponse<any>> => {
|
||||
return request.delete(`/api/users/${id}/delete/`)
|
||||
},
|
||||
|
||||
// 批量删除用户
|
||||
batchDeleteUsers: (ids: number[]): Promise<ApiResponse<any>> => {
|
||||
return request.post('/api/admin/users/batch-delete/', { user_ids: ids })
|
||||
},
|
||||
|
||||
// 获取当前用户信息
|
||||
getUserInfo: (): Promise<ApiResponse<User>> => {
|
||||
return request.get('/api/auth/user/info/')
|
||||
},
|
||||
|
||||
// 更新当前用户信息
|
||||
updateUserInfo: (data: Partial<User>): Promise<ApiResponse<User>> => {
|
||||
return request.put('/api/auth/user/info/update/', data)
|
||||
},
|
||||
|
||||
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)
|
||||
},
|
||||
|
||||
// 获取所有角色列表
|
||||
getRoleList: (): Promise<ApiResponse<Role[]>> => {
|
||||
return request.get('/api/roles/')
|
||||
}
|
||||
}
|
||||
643
hertz_server_django_ui/src/api/yolo.ts
Normal file
643
hertz_server_django_ui/src/api/yolo.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// YOLO检测相关接口类型定义
|
||||
export interface YoloDetectionRequest {
|
||||
image: File
|
||||
model_id?: string
|
||||
confidence_threshold?: number
|
||||
nms_threshold?: number
|
||||
}
|
||||
|
||||
export interface DetectionBbox {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface YoloDetection {
|
||||
class_id: number
|
||||
class_name: string
|
||||
confidence: number
|
||||
bbox: DetectionBbox
|
||||
}
|
||||
|
||||
export interface YoloDetectionResponse {
|
||||
message: string
|
||||
data?: {
|
||||
detection_id: number
|
||||
result_file_url: string
|
||||
original_file_url: string
|
||||
object_count: number
|
||||
detected_categories: string[]
|
||||
confidence_scores: number[]
|
||||
avg_confidence: number | null
|
||||
processing_time: number
|
||||
model_used: string
|
||||
confidence_threshold: number
|
||||
user_id: number
|
||||
user_name: string
|
||||
alert_level?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
}
|
||||
|
||||
export interface YoloModel {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
classes: string[]
|
||||
is_active: boolean
|
||||
is_enabled: boolean
|
||||
model_file: string
|
||||
model_folder_path: string
|
||||
model_path: string
|
||||
weights_folder_path: string
|
||||
categories: { [key: string]: any }
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface YoloModelListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
models: YoloModel[]
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
// 数据集管理相关类型
|
||||
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检测
|
||||
async detectImage(detectionRequest: YoloDetectionRequest): Promise<YoloDetectionResponse> {
|
||||
console.log('🔍 构建检测请求:', detectionRequest)
|
||||
console.log('📁 文件对象详情:', {
|
||||
name: detectionRequest.image.name,
|
||||
size: detectionRequest.image.size,
|
||||
type: detectionRequest.image.type,
|
||||
lastModified: detectionRequest.image.lastModified
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', detectionRequest.image)
|
||||
|
||||
if (detectionRequest.model_id) {
|
||||
formData.append('model_id', detectionRequest.model_id)
|
||||
}
|
||||
if (detectionRequest.confidence_threshold) {
|
||||
formData.append('confidence_threshold', detectionRequest.confidence_threshold.toString())
|
||||
}
|
||||
if (detectionRequest.nms_threshold) {
|
||||
formData.append('nms_threshold', detectionRequest.nms_threshold.toString())
|
||||
}
|
||||
|
||||
// 调试FormData内容
|
||||
console.log('📤 FormData内容:')
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value instanceof File) {
|
||||
console.log(` ${key}: File(${value.name}, ${value.size} bytes, ${value.type})`)
|
||||
} else {
|
||||
console.log(` ${key}:`, value)
|
||||
}
|
||||
}
|
||||
|
||||
return request.post('/api/yolo/detect/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取当前启用的YOLO模型信息
|
||||
async getCurrentEnabledModel(): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
|
||||
// 关闭全局错误提示,由调用方(如 YOLO 检测页面)自行处理“未启用模型”等业务文案
|
||||
return request.get('/api/yolo/models/enabled/', { showError: false })
|
||||
},
|
||||
|
||||
// 获取模型详情
|
||||
async getModelInfo(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
|
||||
return request.get(`/api/yolo/models/${modelId}`)
|
||||
},
|
||||
|
||||
// 批量检测
|
||||
async detectBatch(images: File[], modelId?: string): Promise<YoloDetectionResponse[]> {
|
||||
const promises = images.map(image =>
|
||||
this.detectImage({
|
||||
image,
|
||||
model_id: modelId,
|
||||
confidence_threshold: 0.5,
|
||||
nms_threshold: 0.4
|
||||
})
|
||||
)
|
||||
|
||||
return Promise.all(promises)
|
||||
},
|
||||
|
||||
// 获取模型列表
|
||||
async getModels(): Promise<{ success: boolean; data?: YoloModel[]; message?: string }> {
|
||||
return request.get('/api/yolo/models/')
|
||||
},
|
||||
|
||||
// 上传模型
|
||||
async uploadModel(formData: FormData): Promise<{ success: boolean; message?: string }> {
|
||||
// 使用专门的upload方法,它会自动处理Content-Type
|
||||
return request.upload('/api/yolo/upload/', formData)
|
||||
},
|
||||
|
||||
// 更新模型信息
|
||||
async updateModel(modelId: string, data: { name: string; version: string }): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
|
||||
return request.put(`/api/yolo/models/${modelId}/update/`, data)
|
||||
},
|
||||
|
||||
// 删除模型
|
||||
async deleteModel(modelId: string): Promise<{ success: boolean; message?: string }> {
|
||||
return request.delete(`/api/yolo/models/${modelId}/delete/`)
|
||||
},
|
||||
|
||||
// 启用模型
|
||||
async enableModel(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
|
||||
return request.post(`/api/yolo/models/${modelId}/enable/`)
|
||||
},
|
||||
|
||||
// 获取模型详情
|
||||
async getModelDetail(modelId: string): Promise<{ success: boolean; data?: YoloModel; message?: string }> {
|
||||
return request.get(`/api/yolo/models/${modelId}/`)
|
||||
},
|
||||
|
||||
// 获取检测历史记录列表
|
||||
async getDetectionHistory(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
model_id?: string
|
||||
}): Promise<{ success: boolean; data?: DetectionHistoryRecord[]; message?: string }> {
|
||||
return request.get('/api/yolo/detections/', { params })
|
||||
},
|
||||
|
||||
// 获取检测记录详情
|
||||
async getDetectionDetail(recordId: string): Promise<{ success: boolean; data?: DetectionHistoryRecord; message?: string }> {
|
||||
return request.get(`/api/detections/${recordId}/`)
|
||||
},
|
||||
|
||||
// 删除检测记录
|
||||
async deleteDetection(recordId: string): Promise<{ success: boolean; message?: string }> {
|
||||
return request.delete(`/api/yolo/detections/${recordId}/delete/`)
|
||||
},
|
||||
|
||||
// 批量删除检测记录
|
||||
async batchDeleteDetections(ids: number[]): Promise<{ success: boolean; message?: string }> {
|
||||
return request.post('/api/yolo/detections/batch-delete/', { ids })
|
||||
},
|
||||
|
||||
// 获取检测统计
|
||||
async getDetectionStats(): Promise<{ success: boolean; data?: any; message?: string }> {
|
||||
return request.get('/api/yolo/stats/')
|
||||
},
|
||||
|
||||
// 数据集管理相关接口
|
||||
// 上传数据集
|
||||
async 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 }> {
|
||||
return request.get('/api/yolo/categories/')
|
||||
},
|
||||
|
||||
// 获取警告等级详情
|
||||
async getAlertLevelDetail(levelId: string): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
|
||||
return request.get(`/api/yolo/categories/${levelId}/`)
|
||||
},
|
||||
|
||||
// 更新警告等级
|
||||
async updateAlertLevel(levelId: string, data: { alert_level?: 'low' | 'medium' | 'high'; alias?: string }): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
|
||||
return request.put(`/api/yolo/categories/${levelId}/update/`, data)
|
||||
},
|
||||
|
||||
// 切换警告等级状态
|
||||
async toggleAlertLevelStatus(levelId: string): Promise<{ success: boolean; data?: AlertLevel; message?: string }> {
|
||||
return request.post(`/api/yolo/categories/${levelId}/toggle-status/`)
|
||||
},
|
||||
|
||||
// 获取活跃的警告等级列表
|
||||
async getActiveAlertLevels(): Promise<{ success: boolean; data?: AlertLevel[]; message?: string }> {
|
||||
return request.get('/api/yolo/categories/active/')
|
||||
},
|
||||
|
||||
// 上传并转换PT模型为ONNX格式
|
||||
async uploadAndConvertToOnnx(formData: FormData): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
onnx_path?: string
|
||||
onnx_url?: string
|
||||
download_url?: string
|
||||
onnx_relative_path?: string
|
||||
file_name?: string
|
||||
labels_download_url?: string
|
||||
labels_relative_path?: string
|
||||
classes?: string[]
|
||||
}
|
||||
}> {
|
||||
// 适配后端 @views.py 中的 upload_pt_convert_onnx 实现
|
||||
// 统一走 /api/upload_pt_convert_onnx
|
||||
// 按你的后端接口:/yolo/onnx/upload/
|
||||
// 注意带上结尾斜杠,避免 404
|
||||
return request.upload('/api/yolo/onnx/upload/', formData)
|
||||
}
|
||||
}
|
||||
|
||||
// 警告等级管理相关接口
|
||||
export interface AlertLevel {
|
||||
id: number
|
||||
model: number
|
||||
model_name: string
|
||||
name: string
|
||||
alias: string
|
||||
display_name: string
|
||||
category_id: number
|
||||
alert_level: 'low' | 'medium' | 'high'
|
||||
alert_level_display: string
|
||||
is_active: boolean
|
||||
// 前端编辑状态字段
|
||||
editingAlias?: boolean
|
||||
tempAlias?: string
|
||||
}
|
||||
|
||||
// 用户检测历史相关接口
|
||||
export interface DetectionHistoryRecord {
|
||||
id: number
|
||||
user_id: number
|
||||
original_filename: string
|
||||
result_filename: string
|
||||
original_file: string
|
||||
result_file: string
|
||||
detection_type: 'image' | 'video'
|
||||
object_count: number
|
||||
detected_categories: string[]
|
||||
confidence_scores: number[]
|
||||
avg_confidence: number | null
|
||||
processing_time: number
|
||||
model_name: string
|
||||
model_info: any
|
||||
created_at: string
|
||||
confidence_threshold?: number // 置信度阈值(原始设置值)
|
||||
// 为了兼容前端显示,添加计算字段
|
||||
filename?: string
|
||||
image_url?: string
|
||||
detections?: YoloDetection[]
|
||||
}
|
||||
|
||||
export interface DetectionHistoryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
class_filter?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
model_id?: string
|
||||
}
|
||||
|
||||
export interface DetectionHistoryResponse {
|
||||
success?: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
records: DetectionHistoryRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
} | DetectionHistoryRecord[]
|
||||
// 支持直接返回数组的情况
|
||||
results?: DetectionHistoryRecord[]
|
||||
count?: number
|
||||
// 支持Django REST framework的分页格式
|
||||
next?: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
// 用户检测历史API
|
||||
export const detectionHistoryApi = {
|
||||
// 获取用户检测历史
|
||||
async getUserDetectionHistory(userId: number, params: DetectionHistoryParams = {}): Promise<DetectionHistoryResponse> {
|
||||
return request.get('/api/yolo/detections/', {
|
||||
params: {
|
||||
user_id: userId,
|
||||
...params
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取检测记录详情
|
||||
async getDetectionRecordDetail(recordId: number): Promise<{
|
||||
success?: boolean
|
||||
code?: number
|
||||
message?: string
|
||||
data?: DetectionHistoryRecord
|
||||
}> {
|
||||
return request.get(`/api/yolo/detections/${recordId}/`)
|
||||
},
|
||||
|
||||
// 删除检测记录
|
||||
async deleteDetectionRecord(userId: number, recordId: string): Promise<{ success: boolean; message?: string }> {
|
||||
return request.delete(`/api/yolo/detections/${recordId}/delete/`)
|
||||
},
|
||||
|
||||
// 批量删除检测记录
|
||||
async batchDeleteDetectionRecords(userId: number, recordIds: string[]): Promise<{ success: boolean; message?: string }> {
|
||||
return request.post('/api/yolo/detections/batch-delete/', { ids: recordIds })
|
||||
},
|
||||
|
||||
// 导出检测历史
|
||||
async exportDetectionHistory(userId: number, params: DetectionHistoryParams = {}): Promise<Blob> {
|
||||
const response = await request.get('/api/yolo/detections/export/', {
|
||||
params: {
|
||||
user_id: userId,
|
||||
...params
|
||||
},
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response
|
||||
},
|
||||
|
||||
// 获取检测统计信息
|
||||
async getDetectionStats(userId: number): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
total_detections: number
|
||||
total_images: number
|
||||
class_counts: Record<string, number>
|
||||
recent_activity: Array<{
|
||||
date: string
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
message?: string
|
||||
}> {
|
||||
return request.get('/api/yolo/detections/stats/', {
|
||||
params: { user_id: userId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 告警相关接口类型定义
|
||||
export interface AlertRecord {
|
||||
id: number
|
||||
detection_record: number
|
||||
detection_info: {
|
||||
id: number
|
||||
detection_type: string
|
||||
original_filename: string
|
||||
result_filename: string
|
||||
object_count: number
|
||||
avg_confidence: number
|
||||
}
|
||||
user: number
|
||||
user_name: string
|
||||
alert_level: string
|
||||
alert_level_display: string
|
||||
alert_category: string
|
||||
category: number
|
||||
category_info: {
|
||||
id: number
|
||||
name: string
|
||||
alert_level: string
|
||||
alert_level_display: string
|
||||
}
|
||||
status: string
|
||||
created_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
// 告警管理API
|
||||
export const alertApi = {
|
||||
// 获取所有告警记录
|
||||
async getAllAlerts(): Promise<{ success: boolean; data?: AlertRecord[]; message?: string }> {
|
||||
return request.get('/api/yolo/alerts/')
|
||||
},
|
||||
|
||||
// 获取当前用户的告警记录
|
||||
async getUserAlerts(userId: string): Promise<{ success: boolean; data?: AlertRecord[]; message?: string }> {
|
||||
return request.get(`/api/yolo/users/${userId}/alerts/`)
|
||||
},
|
||||
|
||||
// 处理告警(更新状态)
|
||||
async updateAlertStatus(alertId: string, status: string): Promise<{ success: boolean; data?: AlertRecord; message?: string }> {
|
||||
return request.put(`/api/yolo/alerts/${alertId}/update-status/`, { status })
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出
|
||||
export default yoloApi
|
||||
85
hertz_server_django_ui/src/config/hertz_modules.ts
Normal file
85
hertz_server_django_ui/src/config/hertz_modules.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type HertzModuleGroup = 'admin' | 'user'
|
||||
|
||||
export interface HertzModule {
|
||||
key: string
|
||||
label: string
|
||||
group: HertzModuleGroup
|
||||
description?: string
|
||||
defaultEnabled: boolean
|
||||
}
|
||||
|
||||
export const HERTZ_MODULES: HertzModule[] = [
|
||||
{ key: 'admin.user-management', label: '管理端 · 用户管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.department-management', label: '管理端 · 部门管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.menu-management', label: '管理端 · 菜单管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.role-management', label: '管理端 · 角色管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.notification-management', label: '管理端 · 通知管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.log-management', label: '管理端 · 日志管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.knowledge-base', label: '管理端 · 文章管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.yolo-model', label: '管理端 · YOLO 模型相关', group: 'admin', defaultEnabled: true },
|
||||
|
||||
{ key: 'user.system-monitor', label: '用户端 · 系统监控', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.ai-chat', label: '用户端 · AI 助手', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.yolo-detection', label: '用户端 · YOLO 检测', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.live-detection', label: '用户端 · 实时检测', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.detection-history', label: '用户端 · 检测历史', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.alert-center', label: '用户端 · 告警中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.notice-center', label: '用户端 · 通知中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.knowledge-center', label: '用户端 · 文章中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.kb-center', label: '用户端 · 知识库中心', group: 'user', defaultEnabled: true },
|
||||
]
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'hertz_enabled_modules'
|
||||
|
||||
export function getEnabledModuleKeys(): string[] {
|
||||
const fallback = HERTZ_MODULES.filter(m => m.defaultEnabled).map(m => m.key)
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY)
|
||||
if (!stored) return fallback
|
||||
const parsed = JSON.parse(stored)
|
||||
if (Array.isArray(parsed)) {
|
||||
const valid = parsed.filter((k): k is string => typeof k === 'string')
|
||||
// 自动合并新增的默认启用模块,避免新模块在已有选择下被永久隐藏
|
||||
const missingDefaults = HERTZ_MODULES
|
||||
.filter(m => m.defaultEnabled && !valid.includes(m.key))
|
||||
.map(m => m.key)
|
||||
return [...valid, ...missingDefaults]
|
||||
}
|
||||
return fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setEnabledModuleKeys(keys: string[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(keys))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function isModuleEnabled(moduleKey?: string, enabledKeys?: string[]): boolean {
|
||||
if (!moduleKey) return true
|
||||
const keys = enabledKeys ?? getEnabledModuleKeys()
|
||||
return keys.indexOf(moduleKey) !== -1
|
||||
}
|
||||
|
||||
export function getModulesByGroup(group: HertzModuleGroup): HertzModule[] {
|
||||
return HERTZ_MODULES.filter(m => m.group === group)
|
||||
}
|
||||
|
||||
export function hasModuleSelection(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return window.localStorage.getItem(LOCAL_STORAGE_KEY) !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
159
hertz_server_django_ui/src/locales/en-US.ts
Normal file
159
hertz_server_django_ui/src/locales/en-US.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
export default {
|
||||
common: {
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
search: 'Search',
|
||||
reset: 'Reset',
|
||||
loading: 'Loading...',
|
||||
noData: 'No Data',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
dashboard: 'Dashboard',
|
||||
user: 'User Management',
|
||||
role: 'Role Management',
|
||||
menu: 'Menu Management',
|
||||
settings: 'System Settings',
|
||||
profile: 'Profile',
|
||||
logout: 'Logout',
|
||||
},
|
||||
login: {
|
||||
title: 'Login',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
login: 'Login',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
rememberMe: 'Remember Me',
|
||||
},
|
||||
success: {
|
||||
// General success messages
|
||||
operationSuccess: 'Operation Successful',
|
||||
saveSuccess: 'Save Successful',
|
||||
deleteSuccess: 'Delete Successful',
|
||||
updateSuccess: 'Update Successful',
|
||||
|
||||
// Login and registration related success messages
|
||||
loginSuccess: 'Login Successful',
|
||||
registerSuccess: 'Registration Successful! Please Login',
|
||||
logoutSuccess: 'Logout Successful',
|
||||
emailCodeSent: 'Verification Code Sent to Your Email',
|
||||
|
||||
// User management related success messages
|
||||
userCreated: 'User Created Successfully',
|
||||
userUpdated: 'User Information Updated Successfully',
|
||||
userDeleted: 'User Deleted Successfully',
|
||||
roleAssigned: 'Role Assigned Successfully',
|
||||
|
||||
// Other operation success messages
|
||||
uploadSuccess: 'File Upload Successful',
|
||||
downloadSuccess: 'File Download Successful',
|
||||
copySuccess: 'Copy Successful',
|
||||
},
|
||||
error: {
|
||||
// General errors
|
||||
// 404: 'Page Not Found',
|
||||
403: 'Access Denied, Please Contact Administrator',
|
||||
500: 'Internal Server Error, Please Try Again Later',
|
||||
networkError: 'Network Connection Failed, Please Check Network Settings',
|
||||
timeout: 'Request Timeout, Please Try Again Later',
|
||||
|
||||
// Login related errors
|
||||
loginFailed: 'Login Failed, Please Check Username and Password',
|
||||
usernameRequired: 'Please Enter Username',
|
||||
passwordRequired: 'Please Enter Password',
|
||||
captchaRequired: 'Please Enter Captcha',
|
||||
captchaError: 'Captcha Error, Please Re-enter (Case Sensitive)',
|
||||
captchaExpired: 'Captcha Expired, Please Refresh and Re-enter',
|
||||
accountLocked: 'Account Locked, Please Contact Administrator',
|
||||
accountDisabled: 'Account Disabled, Please Contact Administrator',
|
||||
passwordExpired: 'Password Expired, Please Change Password',
|
||||
loginAttemptsExceeded: 'Too Many Login Attempts, Account Temporarily Locked',
|
||||
|
||||
// Registration related errors
|
||||
registerFailed: 'Registration Failed, Please Check Input Information',
|
||||
usernameExists: 'Username Already Exists, Please Choose Another',
|
||||
emailExists: 'Email Already Registered, Please Use Another Email',
|
||||
phoneExists: 'Phone Number Already Registered, Please Use Another',
|
||||
emailFormatError: 'Invalid Email Format, Please Enter Valid Email',
|
||||
phoneFormatError: 'Invalid Phone Format, Please Enter 11-digit Phone Number',
|
||||
passwordTooWeak: 'Password Too Weak, Please Include Uppercase, Lowercase, Numbers and Special Characters',
|
||||
passwordMismatch: 'Passwords Do Not Match',
|
||||
emailCodeError: 'Email Verification Code Error or Expired',
|
||||
emailCodeRequired: 'Please Enter Email Verification Code',
|
||||
emailCodeLength: 'Verification Code Must Be 6 Digits',
|
||||
emailRequired: 'Please Enter Email',
|
||||
usernameLength: 'Username Length Must Be 3-20 Characters',
|
||||
passwordLength: 'Password Length Must Be 6-20 Characters',
|
||||
confirmPasswordRequired: 'Please Confirm Password',
|
||||
phoneRequired: 'Please Enter Phone Number',
|
||||
realNameRequired: 'Please Enter Real Name',
|
||||
realNameLength: 'Name Length Must Be 2-10 Characters',
|
||||
|
||||
// Permission related errors
|
||||
accessDenied: 'Access Denied, You Do Not Have Permission to Perform This Action',
|
||||
roleNotFound: 'Role Not Found or Deleted',
|
||||
permissionDenied: 'Permission Denied, Cannot Perform This Action',
|
||||
tokenExpired: 'Login Expired, Please Login Again',
|
||||
tokenInvalid: 'Invalid Login Status, Please Login Again',
|
||||
|
||||
// User management related errors
|
||||
userNotFound: 'User Not Found or Deleted',
|
||||
userCreateFailed: 'Failed to Create User, Please Check Input Information',
|
||||
userUpdateFailed: 'Failed to Update User Information',
|
||||
userDeleteFailed: 'Failed to Delete User, User May Be In Use',
|
||||
cannotDeleteSelf: 'Cannot Delete Your Own Account',
|
||||
cannotDeleteAdmin: 'Cannot Delete Administrator Account',
|
||||
|
||||
// Department management related errors
|
||||
departmentNotFound: 'Department Not Found or Deleted',
|
||||
departmentNameExists: 'Department Name Already Exists',
|
||||
departmentHasUsers: 'Department Has Users, Cannot Delete',
|
||||
departmentCreateFailed: 'Failed to Create Department',
|
||||
departmentUpdateFailed: 'Failed to Update Department Information',
|
||||
departmentDeleteFailed: 'Failed to Delete Department',
|
||||
|
||||
// Role management related errors
|
||||
roleNameExists: 'Role Name Already Exists',
|
||||
roleCreateFailed: 'Failed to Create Role',
|
||||
roleUpdateFailed: 'Failed to Update Role Information',
|
||||
roleDeleteFailed: 'Failed to Delete Role',
|
||||
roleInUse: 'Role In Use, Cannot Delete',
|
||||
|
||||
// File upload related errors
|
||||
fileUploadFailed: 'File Upload Failed',
|
||||
fileSizeExceeded: 'File Size Exceeded Limit',
|
||||
fileTypeNotSupported: 'File Type Not Supported',
|
||||
fileRequired: 'Please Select File to Upload',
|
||||
|
||||
// Data validation related errors
|
||||
invalidInput: 'Invalid Input Data Format',
|
||||
requiredFieldMissing: 'Required Field Cannot Be Empty',
|
||||
fieldTooLong: 'Input Content Exceeds Length Limit',
|
||||
fieldTooShort: 'Input Content Length Insufficient',
|
||||
invalidDate: 'Invalid Date Format',
|
||||
invalidNumber: 'Invalid Number Format',
|
||||
|
||||
// Operation related errors
|
||||
operationFailed: 'Operation Failed, Please Try Again Later',
|
||||
saveSuccess: 'Save Successful',
|
||||
saveFailed: 'Save Failed, Please Check Input Information',
|
||||
deleteSuccess: 'Delete Successful',
|
||||
deleteFailed: 'Delete Failed, Please Try Again Later',
|
||||
updateSuccess: 'Update Successful',
|
||||
updateFailed: 'Update Failed, Please Check Input Information',
|
||||
|
||||
// System related errors
|
||||
systemMaintenance: 'System Under Maintenance, Please Visit Later',
|
||||
serviceUnavailable: 'Service Temporarily Unavailable, Please Try Again Later',
|
||||
databaseError: 'Database Connection Error, Please Contact Technical Support',
|
||||
configError: 'System Configuration Error, Please Contact Administrator',
|
||||
},
|
||||
}
|
||||
18
hertz_server_django_ui/src/locales/index.ts
Normal file
18
hertz_server_django_ui/src/locales/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import enUS from './en-US'
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'en-US',
|
||||
messages,
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
})
|
||||
|
||||
export default i18n
|
||||
172
hertz_server_django_ui/src/locales/zh-CN.ts
Normal file
172
hertz_server_django_ui/src/locales/zh-CN.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export default {
|
||||
common: {
|
||||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '添 加',
|
||||
search: '搜索',
|
||||
reset: '重置',
|
||||
loading: '加载中...',
|
||||
noData: '暂无数据',
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
},
|
||||
nav: {
|
||||
home: '首页',
|
||||
dashboard: '仪表板',
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
menu: '菜单管理',
|
||||
settings: '系统设置',
|
||||
profile: '个人资料',
|
||||
logout: '退出登录',
|
||||
},
|
||||
login: {
|
||||
title: '登录',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
login: '登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
rememberMe: '记住我',
|
||||
},
|
||||
register: {
|
||||
title: '注册',
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
register: '注册',
|
||||
agreement: '我已阅读并同意',
|
||||
userAgreement: '用户协议',
|
||||
privacyPolicy: '隐私政策',
|
||||
hasAccount: '已有账号?',
|
||||
goToLogin: '立即登录',
|
||||
},
|
||||
success: {
|
||||
// 通用成功提示
|
||||
operationSuccess: '操作成功',
|
||||
saveSuccess: '保存成功',
|
||||
deleteSuccess: '删除成功',
|
||||
updateSuccess: '更新成功',
|
||||
|
||||
// 登录注册相关成功提示
|
||||
loginSuccess: '登录成功',
|
||||
registerSuccess: '注册成功!请前往登录',
|
||||
logoutSuccess: '退出登录成功',
|
||||
emailCodeSent: '验证码已发送到您的邮箱',
|
||||
|
||||
// 用户管理相关成功提示
|
||||
userCreated: '用户创建成功',
|
||||
userUpdated: '用户信息更新成功',
|
||||
userDeleted: '用户删除成功',
|
||||
roleAssigned: '角色分配成功',
|
||||
|
||||
// 其他操作成功提示
|
||||
uploadSuccess: '文件上传成功',
|
||||
downloadSuccess: '文件下载成功',
|
||||
copySuccess: '复制成功',
|
||||
},
|
||||
error: {
|
||||
// 通用错误
|
||||
// 404: '页面未找到',
|
||||
403: '权限不足,请联系管理员',
|
||||
500: '服务器内部错误,请稍后重试',
|
||||
networkError: '网络连接失败,请检查网络设置',
|
||||
timeout: '请求超时,请稍后重试',
|
||||
|
||||
// 登录相关错误
|
||||
loginFailed: '登录失败,请检查用户名和密码',
|
||||
usernameRequired: '请输入用户名',
|
||||
passwordRequired: '请输入密码',
|
||||
captchaRequired: '请输入验证码',
|
||||
captchaError: '验证码错误,请重新输入(区分大小写)',
|
||||
captchaExpired: '验证码已过期,请刷新后重新输入',
|
||||
accountLocked: '账户已被锁定,请联系管理员',
|
||||
accountDisabled: '账户已被禁用,请联系管理员',
|
||||
passwordExpired: '密码已过期,请修改密码',
|
||||
loginAttemptsExceeded: '登录尝试次数过多,账户已被临时锁定',
|
||||
|
||||
// 注册相关错误
|
||||
registerFailed: '注册失败,请检查输入信息',
|
||||
usernameExists: '用户名已存在,请选择其他用户名',
|
||||
emailExists: '邮箱已被注册,请使用其他邮箱',
|
||||
phoneExists: '手机号已被注册,请使用其他手机号',
|
||||
emailFormatError: '邮箱格式不正确,请输入有效的邮箱地址',
|
||||
phoneFormatError: '手机号格式不正确,请输入11位手机号',
|
||||
passwordTooWeak: '密码强度不足,请包含大小写字母、数字和特殊字符',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
emailCodeError: '邮箱验证码错误或已过期',
|
||||
emailCodeRequired: '请输入邮箱验证码',
|
||||
emailCodeLength: '验证码长度为6位',
|
||||
emailRequired: '请输入邮箱',
|
||||
usernameLength: '用户名长度为3-20个字符',
|
||||
passwordLength: '密码长度为6-20个字符',
|
||||
confirmPasswordRequired: '请确认密码',
|
||||
phoneRequired: '请输入手机号',
|
||||
realNameRequired: '请输入真实姓名',
|
||||
realNameLength: '姓名长度为2-10个字符',
|
||||
|
||||
// 权限相关错误
|
||||
accessDenied: '访问被拒绝,您没有执行此操作的权限',
|
||||
roleNotFound: '角色不存在或已被删除',
|
||||
permissionDenied: '权限不足,无法执行此操作',
|
||||
tokenExpired: '登录已过期,请重新登录',
|
||||
tokenInvalid: '登录状态无效,请重新登录',
|
||||
|
||||
// 用户管理相关错误
|
||||
userNotFound: '用户不存在或已被删除',
|
||||
userCreateFailed: '创建用户失败,请检查输入信息',
|
||||
userUpdateFailed: '更新用户信息失败',
|
||||
userDeleteFailed: '删除用户失败,该用户可能正在使用中',
|
||||
cannotDeleteSelf: '不能删除自己的账户',
|
||||
cannotDeleteAdmin: '不能删除管理员账户',
|
||||
|
||||
// 部门管理相关错误
|
||||
departmentNotFound: '部门不存在或已被删除',
|
||||
departmentNameExists: '部门名称已存在',
|
||||
departmentHasUsers: '部门下还有用户,无法删除',
|
||||
departmentCreateFailed: '创建部门失败',
|
||||
departmentUpdateFailed: '更新部门信息失败',
|
||||
departmentDeleteFailed: '删除部门失败',
|
||||
|
||||
// 角色管理相关错误
|
||||
roleNameExists: '角色名称已存在',
|
||||
roleCreateFailed: '创建角色失败',
|
||||
roleUpdateFailed: '更新角色信息失败',
|
||||
roleDeleteFailed: '删除角色失败',
|
||||
roleInUse: '角色正在使用中,无法删除',
|
||||
|
||||
// 文件上传相关错误
|
||||
fileUploadFailed: '文件上传失败',
|
||||
fileSizeExceeded: '文件大小超出限制',
|
||||
fileTypeNotSupported: '不支持的文件类型',
|
||||
fileRequired: '请选择要上传的文件',
|
||||
|
||||
// 数据验证相关错误
|
||||
invalidInput: '输入数据格式不正确',
|
||||
requiredFieldMissing: '必填字段不能为空',
|
||||
fieldTooLong: '输入内容超出长度限制',
|
||||
fieldTooShort: '输入内容长度不足',
|
||||
invalidDate: '日期格式不正确',
|
||||
invalidNumber: '数字格式不正确',
|
||||
|
||||
// 操作相关错误
|
||||
operationFailed: '操作失败,请稍后重试',
|
||||
saveSuccess: '保存成功',
|
||||
saveFailed: '保存失败,请检查输入信息',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteFailed: '删除失败,请稍后重试',
|
||||
updateSuccess: '更新成功',
|
||||
updateFailed: '更新失败,请检查输入信息',
|
||||
|
||||
// 系统相关错误
|
||||
systemMaintenance: '系统正在维护中,请稍后访问',
|
||||
serviceUnavailable: '服务暂时不可用,请稍后重试',
|
||||
databaseError: '数据库连接错误,请联系技术支持',
|
||||
configError: '系统配置错误,请联系管理员',
|
||||
},
|
||||
}
|
||||
47
hertz_server_django_ui/src/main.ts
Normal file
47
hertz_server_django_ui/src/main.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { i18n } from './locales'
|
||||
import { checkEnvironmentVariables, validateEnvironment } from './utils/hertz_env'
|
||||
import './styles/index.scss'
|
||||
|
||||
// 导入Ant Design Vue
|
||||
import 'ant-design-vue/dist/antd.css'
|
||||
|
||||
// 开发环境检查
|
||||
if (import.meta.env.DEV) {
|
||||
checkEnvironmentVariables()
|
||||
validateEnvironment()
|
||||
}
|
||||
|
||||
// 创建Vue应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 使用Pinia状态管理
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// 使用路由
|
||||
app.use(router)
|
||||
|
||||
// 使用国际化
|
||||
app.use(i18n)
|
||||
|
||||
// 初始化应用设置
|
||||
import { useAppStore } from './stores/hertz_app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initAppSettings()
|
||||
|
||||
// 检查用户认证状态
|
||||
import { useUserStore } from './stores/hertz_user'
|
||||
const userStore = useUserStore()
|
||||
userStore.checkAuth()
|
||||
|
||||
// 初始化主题(必须在挂载前加载)
|
||||
import { useThemeStore } from './stores/hertz_theme'
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.loadTheme()
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
459
hertz_server_django_ui/src/router/admin_menu.ts
Normal file
459
hertz_server_django_ui/src/router/admin_menu.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { getEnabledModuleKeys, isModuleEnabled } from "@/config/hertz_modules";
|
||||
|
||||
// 角色权限枚举
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
SYSTEM_ADMIN = 'system_admin',
|
||||
NORMAL_USER = 'normal_user',
|
||||
SUPER_ADMIN = 'super_admin'
|
||||
}
|
||||
|
||||
// 统一菜单配置接口 - 只需要在这里配置一次
|
||||
export interface AdminMenuItem {
|
||||
key: string; // 菜单唯一标识
|
||||
title: string; // 菜单标题
|
||||
icon?: string; // 菜单图标
|
||||
path: string; // 路由路径
|
||||
component: string; // 组件路径(相对于@/views/admin_page/)
|
||||
isDefault?: boolean; // 是否为默认路由(首页)
|
||||
roles?: UserRole[]; // 允许访问的角色,不设置则使用默认管理员角色
|
||||
permission?: string; // 所需权限标识符
|
||||
children?: AdminMenuItem[]; // 子菜单
|
||||
moduleKey?: string;
|
||||
}
|
||||
|
||||
// 🎯 统一配置中心 - 只需要在这里修改菜单配置
|
||||
export const ADMIN_MENU_CONFIG: AdminMenuItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
title: "仪表盘",
|
||||
icon: "DashboardOutlined",
|
||||
path: "/admin",
|
||||
component: "Dashboard.vue",
|
||||
isDefault: true, // 标记为默认首页
|
||||
},
|
||||
{
|
||||
key: "user-management",
|
||||
title: "用户管理",
|
||||
icon: "UserOutlined",
|
||||
path: "/admin/user-management",
|
||||
component: "UserManagement.vue",
|
||||
permission: "system:user:list", // 需要用户列表权限
|
||||
moduleKey: "admin.user-management",
|
||||
},
|
||||
{
|
||||
key: "department-management",
|
||||
title: "部门管理",
|
||||
icon: "SettingOutlined",
|
||||
path: "/admin/department-management",
|
||||
component: "DepartmentManagement.vue",
|
||||
permission: "system:dept:list", // 需要部门列表权限
|
||||
moduleKey: "admin.department-management",
|
||||
},
|
||||
{
|
||||
key: "menu-management",
|
||||
title: "菜单管理",
|
||||
icon: "SettingOutlined",
|
||||
path: "/admin/menu-management",
|
||||
component: "MenuManagement.vue",
|
||||
permission: "system:menu:list", // 需要菜单列表权限
|
||||
moduleKey: "admin.menu-management",
|
||||
},
|
||||
{
|
||||
key: "teacher",
|
||||
title: "角色管理",
|
||||
icon: "UserOutlined",
|
||||
path: "/admin/teacher",
|
||||
component: "Role.vue",
|
||||
permission: "system:role:list", // 需要角色列表权限
|
||||
moduleKey: "admin.role-management",
|
||||
},
|
||||
{
|
||||
key: "notification-management",
|
||||
title: "通知管理",
|
||||
icon: "UserOutlined",
|
||||
path: "/admin/notification-management",
|
||||
component: "NotificationManagement.vue",
|
||||
permission: "studio:notice:list", // 需要通知列表权限
|
||||
moduleKey: "admin.notification-management",
|
||||
},
|
||||
{
|
||||
key: "log-management",
|
||||
title: "日志管理",
|
||||
icon: "FileSearchOutlined",
|
||||
path: "/admin/log-management",
|
||||
component: "LogManagement.vue",
|
||||
permission: "log.view_operationlog", // 查看操作日志权限
|
||||
moduleKey: "admin.log-management",
|
||||
},
|
||||
{
|
||||
key: "knowledge-base",
|
||||
title: "文章管理",
|
||||
icon: "DatabaseOutlined",
|
||||
path: "/admin/article-management",
|
||||
component: "ArticleManagement.vue",
|
||||
// 菜单访问权限:需要具备文章列表权限
|
||||
permission: "system:knowledge:article:list",
|
||||
moduleKey: "admin.knowledge-base",
|
||||
},
|
||||
{
|
||||
key: "yolo-model",
|
||||
title: "YOLO模型",
|
||||
icon: "ClusterOutlined",
|
||||
path: "/admin/yolo-model",
|
||||
component: "ModelManagement.vue", // 默认显示模型管理页面
|
||||
// 父菜单不设置权限,由子菜单的权限决定是否显示
|
||||
moduleKey: "admin.yolo-model",
|
||||
children: [
|
||||
{
|
||||
key: "model-management",
|
||||
title: "模型管理",
|
||||
icon: "RobotOutlined",
|
||||
path: "/admin/model-management",
|
||||
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: "模型类别管理",
|
||||
icon: "WarningOutlined",
|
||||
path: "/admin/alert-level-management",
|
||||
component: "AlertLevelManagement.vue",
|
||||
permission: "system:yolo:alert:list",
|
||||
},
|
||||
{
|
||||
key: "alert-processing-center",
|
||||
title: "告警处理中心",
|
||||
icon: "BellOutlined",
|
||||
path: "/admin/alert-processing-center",
|
||||
component: "AlertProcessingCenter.vue",
|
||||
permission: "system:yolo:alert:process",
|
||||
},
|
||||
{
|
||||
key: "detection-history-management",
|
||||
title: "检测历史管理",
|
||||
icon: "HistoryOutlined",
|
||||
path: "/admin/detection-history-management",
|
||||
component: "DetectionHistoryManagement.vue",
|
||||
permission: "system:yolo:history:list",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 默认管理员角色 - 修改为空数组,通过自定义权限检查函数处理
|
||||
const DEFAULT_ADMIN_ROLES: UserRole[] = [];
|
||||
|
||||
// 组件映射 - 静态导入以支持Vite分析
|
||||
const COMPONENT_MAP: { [key: string]: () => Promise<any> } = {
|
||||
'Dashboard.vue': () => import("@/views/admin_page/Dashboard.vue"),
|
||||
'UserManagement.vue': () => import("@/views/admin_page/UserManagement.vue"),
|
||||
'DepartmentManagement.vue': () => import("@/views/admin_page/DepartmentManagement.vue"),
|
||||
'Role.vue': () => import("@/views/admin_page/Role.vue"),
|
||||
'MenuManagement.vue': () => import("@/views/admin_page/MenuManagement.vue"),
|
||||
'NotificationManagement.vue': () => import("@/views/admin_page/NotificationManagement.vue"),
|
||||
'LogManagement.vue': () => import("@/views/admin_page/LogManagement.vue"),
|
||||
'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"),
|
||||
};
|
||||
|
||||
// 🚀 自动生成路由配置
|
||||
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) {
|
||||
// 为每个子菜单创建独立的路由
|
||||
item.children.forEach(child => {
|
||||
children.push({
|
||||
path: child.path.replace("/admin/", ""),
|
||||
name: child.key,
|
||||
component: COMPONENT_MAP[child.component] || (() => import("@/views/admin_page/Dashboard.vue")),
|
||||
meta: {
|
||||
title: child.title,
|
||||
requiresAuth: true,
|
||||
roles: child.roles || DEFAULT_ADMIN_ROLES,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有子菜单的普通菜单项
|
||||
children.push({
|
||||
path: item.isDefault ? "" : item.path.replace("/admin/", ""),
|
||||
name: item.key,
|
||||
component: COMPONENT_MAP[item.component] || (() => import("@/views/admin_page/Dashboard.vue")),
|
||||
meta: {
|
||||
title: item.title,
|
||||
requiresAuth: true,
|
||||
roles: item.roles || DEFAULT_ADMIN_ROLES,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🛣️ 生成的管理端路由配置:', children.map(child => ({
|
||||
path: child.path,
|
||||
name: child.name,
|
||||
title: child.meta?.title
|
||||
})));
|
||||
|
||||
return {
|
||||
path: "/admin",
|
||||
name: "Admin",
|
||||
component: () => import("@/views/admin_page/index.vue"),
|
||||
meta: {
|
||||
title: "管理后台",
|
||||
requiresAuth: true,
|
||||
roles: DEFAULT_ADMIN_ROLES,
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
// 🚀 自动生成菜单配置
|
||||
export interface MenuConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
path: string;
|
||||
children?: MenuConfig[];
|
||||
}
|
||||
|
||||
function generateMenuConfig(): MenuConfig[] {
|
||||
return ADMIN_MENU_CONFIG.map(item => ({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
path: item.path,
|
||||
children: item.children?.map(child => ({
|
||||
key: child.key,
|
||||
title: child.title,
|
||||
icon: child.icon,
|
||||
path: child.path,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// 🚀 自动生成路径映射函数
|
||||
function generatePathKeyMapping(): { [path: string]: string } {
|
||||
const mapping: { [path: string]: string } = {};
|
||||
|
||||
function addToMapping(items: AdminMenuItem[], parentPath = '') {
|
||||
items.forEach(item => {
|
||||
mapping[item.path] = item.key;
|
||||
if (item.children) {
|
||||
addToMapping(item.children, item.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addToMapping(ADMIN_MENU_CONFIG);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// 导出的配置和函数
|
||||
export const adminMenuRoutes: RouteRecordRaw = generateAdminRoutes();
|
||||
export const adminMenuConfig: MenuConfig[] = generateMenuConfig();
|
||||
|
||||
// 路径到key的映射
|
||||
const pathKeyMapping = generatePathKeyMapping();
|
||||
|
||||
// 🎯 根据路径获取菜单key - 自动生成
|
||||
export const getMenuKeyByPath = (path: string): string => {
|
||||
// 精确匹配
|
||||
if (pathKeyMapping[path]) {
|
||||
return pathKeyMapping[path];
|
||||
}
|
||||
|
||||
// 模糊匹配
|
||||
for (const [mappedPath, key] of Object.entries(pathKeyMapping)) {
|
||||
if (path.includes(mappedPath) && mappedPath !== '/admin') {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回dashboard
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
// 🎯 根据菜单key获取路径 - 自动生成
|
||||
export const getPathByMenuKey = (key: string): string => {
|
||||
console.log('🔍 查找菜单路径:', key);
|
||||
|
||||
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === key);
|
||||
if (menuItem) {
|
||||
console.log('✅ 找到父菜单路径:', menuItem.path);
|
||||
return menuItem.path;
|
||||
}
|
||||
|
||||
// 在子菜单中查找
|
||||
for (const item of ADMIN_MENU_CONFIG) {
|
||||
if (item.children) {
|
||||
const childItem = item.children.find(child => child.key === key);
|
||||
if (childItem) {
|
||||
console.log('✅ 找到子菜单路径:', childItem.path);
|
||||
return childItem.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('❌ 未找到菜单路径,返回默认路径');
|
||||
return '/admin';
|
||||
};
|
||||
|
||||
// 🎯 根据菜单key获取标题 - 自动生成
|
||||
export const getTitleByMenuKey = (key: string): string => {
|
||||
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === key);
|
||||
if (menuItem) return menuItem.title;
|
||||
|
||||
// 在子菜单中查找
|
||||
for (const item of ADMIN_MENU_CONFIG) {
|
||||
if (item.children) {
|
||||
const childItem = item.children.find(child => child.key === key);
|
||||
if (childItem) return childItem.title;
|
||||
}
|
||||
}
|
||||
|
||||
return '仪表盘';
|
||||
};
|
||||
|
||||
// 菜单权限检查
|
||||
export const hasMenuPermission = (menuKey: string, userRole: string): boolean => {
|
||||
const menuItem = ADMIN_MENU_CONFIG.find(item => item.key === menuKey);
|
||||
if (!menuItem) return false;
|
||||
|
||||
return menuItem.roles ? menuItem.roles.includes(userRole as UserRole) : DEFAULT_ADMIN_ROLES.includes(userRole as UserRole);
|
||||
};
|
||||
|
||||
// 🎯 新增:根据用户权限过滤菜单配置
|
||||
export const getFilteredMenuConfig = (userRoles: string[], userPermissions: string[], userMenuPermissions?: number[]): MenuConfig[] => {
|
||||
const userRole = userRoles[0]; // 取第一个角色作为主要角色
|
||||
|
||||
// 仅管理员角色显示管理端菜单
|
||||
const adminRoles = ['admin', 'system_admin', 'super_admin'];
|
||||
const isAdminRole = userRoles.some(r => adminRoles.includes(r));
|
||||
if (!isAdminRole) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 对 super_admin / system_admin 开放所有管理菜单(忽略权限字符串过滤)
|
||||
const isPrivilegedAdmin = userRoles.includes('super_admin') || userRoles.includes('system_admin');
|
||||
|
||||
const 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,
|
||||
hasChildren: !!(menuItem.children && menuItem.children.length > 0),
|
||||
childrenCount: menuItem.children?.length || 0
|
||||
});
|
||||
|
||||
// 如果菜单没有配置权限要求,则默认允许访问(如仪表盘)
|
||||
if (!menuItem.permission) {
|
||||
console.log(`✅ 菜单 ${menuItem.title} 无权限要求,允许访问`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查用户是否有该菜单所需的权限
|
||||
const hasMenuPermission = isPrivilegedAdmin ? true : hasPermission(menuItem.permission, userPermissions);
|
||||
|
||||
if (!hasMenuPermission) {
|
||||
console.log(`❌ 菜单 ${menuItem.title} 权限不足,拒绝访问`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果有子菜单,过滤子菜单
|
||||
if (menuItem.children && menuItem.children.length > 0) {
|
||||
const filteredChildren = menuItem.children.filter(child => {
|
||||
// 如果子菜单没有配置权限要求,则默认允许访问
|
||||
if (!child.permission) {
|
||||
console.log(`✅ 子菜单 ${child.title} 无权限要求,允许访问`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const childHasPermission = hasPermission(child.permission, userPermissions);
|
||||
console.log(`🔍 子菜单 ${child.title} 权限检查:`, {
|
||||
permission: child.permission,
|
||||
hasPermission: childHasPermission
|
||||
});
|
||||
return childHasPermission;
|
||||
});
|
||||
|
||||
console.log(`📊 菜单 ${menuItem.title} 子菜单过滤结果:`, {
|
||||
originalCount: menuItem.children.length,
|
||||
filteredCount: filteredChildren.length,
|
||||
filteredChildren: filteredChildren.map(c => c.title)
|
||||
});
|
||||
|
||||
// 如果没有任何子菜单有权限,则不显示父菜单
|
||||
if (filteredChildren.length === 0) {
|
||||
console.log(`❌ 菜单 ${menuItem.title} 所有子菜单都无权限,隐藏父菜单`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新子菜单列表
|
||||
menuItem.children = filteredChildren;
|
||||
}
|
||||
|
||||
console.log(`✅ 菜单 ${menuItem.title} 通过权限检查`);
|
||||
return true;
|
||||
}).map(menuItem => ({
|
||||
key: menuItem.key,
|
||||
title: menuItem.title,
|
||||
icon: menuItem.icon,
|
||||
path: menuItem.path,
|
||||
children: menuItem.children?.map(child => ({
|
||||
key: child.key,
|
||||
title: child.title,
|
||||
icon: child.icon,
|
||||
path: child.path
|
||||
}))
|
||||
}));
|
||||
|
||||
return filteredMenus;
|
||||
};
|
||||
|
||||
// 🎯 新增:检查用户是否有任何管理员菜单权限
|
||||
// 修改逻辑:只有normal_user角色不能访问管理端,其他所有角色都可以访问
|
||||
export const hasAnyAdminPermission = (userRoles: string[]): boolean => {
|
||||
// 仅当包含 admin/system_admin/super_admin 之一才视为管理员
|
||||
const adminRoles = ['admin', 'system_admin', 'super_admin'];
|
||||
return userRoles.some(role => adminRoles.includes(role));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定权限
|
||||
*/
|
||||
const hasPermission = (permission: string, userPermissions: string[]): boolean => {
|
||||
return userPermissions.includes(permission);
|
||||
};
|
||||
295
hertz_server_django_ui/src/router/index.ts
Normal file
295
hertz_server_django_ui/src/router/index.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { useUserStore } from "@/stores/hertz_user";
|
||||
import { adminMenuRoutes, UserRole } from "./admin_menu";
|
||||
import { userRoutes } from "./user_menu_ai";
|
||||
import { hasModuleSelection } from "@/config/hertz_modules";
|
||||
|
||||
// 固定路由配置
|
||||
const fixedRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "Home",
|
||||
component: () => import("@/views/Home.vue"),
|
||||
meta: {
|
||||
title: "首页",
|
||||
requiresAuth: false,
|
||||
},
|
||||
children: [...generateDynamicRoutes("public")],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("@/views/Login.vue"),
|
||||
meta: {
|
||||
title: "登录",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/template/modules",
|
||||
name: "ModuleSetup",
|
||||
component: () => import("@/views/ModuleSetup.vue"),
|
||||
meta: {
|
||||
title: "模块配置",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "Register",
|
||||
component: () => import("@/views/register.vue"),
|
||||
meta: {
|
||||
title: "注册",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
// 管理端路由 - 从admin_menu.ts导入
|
||||
adminMenuRoutes,
|
||||
];
|
||||
|
||||
// 动态生成路由配置
|
||||
function generateDynamicRoutes(targetDir: string = ""): RouteRecordRaw[] {
|
||||
if (!targetDir) {
|
||||
return [];
|
||||
}
|
||||
const viewsContext = import.meta.glob("@/views/**/*.vue", { eager: true });
|
||||
|
||||
return Object.entries(viewsContext)
|
||||
.map(([path, component]) => {
|
||||
const relativePath = path.match(/\/views\/(.+?)\.vue$/)?.[1];
|
||||
if (!relativePath) return null;
|
||||
|
||||
const fileName = relativePath.replace(".vue", "");
|
||||
const routeName = fileName.split("/").pop()!;
|
||||
|
||||
// 过滤条件
|
||||
if (targetDir && !fileName.startsWith(targetDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成路径和标题
|
||||
const routePath = `/${fileName.replace(/([A-Z])/g, "$1").toLowerCase()}`;
|
||||
const requiresAuth =
|
||||
(!routePath.startsWith("/demo") && !routePath.startsWith("/public")) || routePath.startsWith("/user_pages")&& routePath.startsWith("/admin_page");
|
||||
const pageTitle = (component as any)?.default?.title;
|
||||
|
||||
// 根据路径设置角色权限
|
||||
let roles: UserRole[] = [];
|
||||
if (routePath.startsWith("/admin_page")) {
|
||||
roles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
|
||||
} else if (routePath.startsWith("/user_pages")) {
|
||||
roles = [UserRole.NORMAL_USER, UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
|
||||
} else if (routePath.startsWith("/demo")) {
|
||||
roles = []; // demo页面不需要特定角色
|
||||
}
|
||||
|
||||
return {
|
||||
path: routePath,
|
||||
name: routeName,
|
||||
component: () => import(/* @vite-ignore */ path),
|
||||
meta: {
|
||||
title: pageTitle,
|
||||
requiresAuth,
|
||||
roles: requiresAuth ? roles : []
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as RouteRecordRaw[];
|
||||
}
|
||||
|
||||
// 合并固定路由和动态路由
|
||||
const routes: RouteRecordRaw[] = [
|
||||
...fixedRoutes,
|
||||
...userRoutes, // 用户菜单路由 - 现在通过统一配置自动生成
|
||||
...generateDynamicRoutes("demo"), // 生成demo文件夹的路由
|
||||
...generateDynamicRoutes("admin_page"),//生成admin_page文件夹的路由
|
||||
// 404页面始终放在最后
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/NotFound.vue"),
|
||||
meta: {
|
||||
title: "页面未找到",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 递归打印路由信息
|
||||
function printRoute(route: RouteRecordRaw, level: number = 0) {
|
||||
const indent = " ".repeat(level);
|
||||
const icon = route.meta.requiresAuth ? "🔒" : "🔓";
|
||||
const auth = route.meta.requiresAuth ? "需要登录" : "公开访问";
|
||||
console.log(`${indent}${icon} ${route.path} → ${route.meta.title} (${auth})`);
|
||||
|
||||
// 递归打印子路由
|
||||
if (route.children && route.children.length > 0) {
|
||||
route.children.forEach((child) => printRoute(child, level + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// 路由调试信息
|
||||
function logRouteInfo() {
|
||||
console.log("🚀 管理系统 路由配置:");
|
||||
console.log("📋 路由列表:");
|
||||
|
||||
routes.forEach((route) => printRoute(route));
|
||||
|
||||
console.log(" ❓ /:pathMatch(.*)* → NotFound (页面未找到)");
|
||||
console.log("✅ 路由配置完成!");
|
||||
}
|
||||
|
||||
// 重定向计数器,防止无限重定向
|
||||
let redirectCount = 0;
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 调试信息
|
||||
console.log('🛡️ 路由守卫检查');
|
||||
console.log('📍 目标路由:', to.path, to.name);
|
||||
console.log('🔐 需要认证:', to.meta.requiresAuth);
|
||||
console.log('👤 用户登录状态:', userStore.isLoggedIn);
|
||||
console.log('🎫 Token:', userStore.token ? '存在' : '不存在');
|
||||
console.log('📋 用户信息:', userStore.userInfo);
|
||||
console.log('🔄 重定向计数:', redirectCount);
|
||||
|
||||
// 模板模式:首次必须先完成模块选择
|
||||
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} - 管理系统`;
|
||||
}
|
||||
|
||||
// 检查是否需要登录
|
||||
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
|
||||
console.log('❌ 需要登录但用户未登录,重定向到登录页');
|
||||
redirectCount++;
|
||||
if (redirectCount > MAX_REDIRECTS) {
|
||||
console.log('⚠️ 重定向次数过多,强制跳转到首页');
|
||||
redirectCount = 0;
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
next({ name: "Login", query: { redirect: to.fullPath } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 已登录用户访问登录页,根据角色重定向到对应首页
|
||||
if (to.name === "Login" && userStore.isLoggedIn) {
|
||||
const userRole = userStore.userInfo?.roles?.[0]?.role_code;
|
||||
console.log('🔄 路由守卫 - 已登录用户访问登录页');
|
||||
console.log('👤 当前用户角色:', userRole);
|
||||
console.log('📋 用户信息:', userStore.userInfo);
|
||||
|
||||
// 重置重定向计数器
|
||||
redirectCount = 0;
|
||||
|
||||
// 仅管理员角色进入管理端,其余(含未定义)进入用户端
|
||||
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
|
||||
const isAdmin = adminRoles.includes(userRole as UserRole);
|
||||
if (isAdmin) {
|
||||
console.log('➡️ 重定向到管理端首页');
|
||||
next({ name: "Admin" });
|
||||
} else {
|
||||
console.log('➡️ 重定向到用户端首页');
|
||||
next({ name: "UserDashboard" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
if (to.meta.requiresAuth && to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
const userRole = userStore.userInfo?.roles?.[0]?.role_code;
|
||||
|
||||
// 特殊处理:如果是管理端路由,使用自定义权限检查
|
||||
let hasPermission = false;
|
||||
if (to.path.startsWith('/admin')) {
|
||||
// 管理端路由:仅 admin/system_admin/super_admin 可访问
|
||||
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN];
|
||||
hasPermission = adminRoles.includes(userRole as UserRole);
|
||||
} else {
|
||||
// 其他路由:使用原有的角色检查逻辑
|
||||
hasPermission = to.meta.roles.length === 0 || to.meta.roles.includes(userRole as UserRole);
|
||||
}
|
||||
|
||||
console.log('🔐 路由权限检查');
|
||||
console.log('📍 目标路由:', to.path, to.name);
|
||||
console.log('🎭 需要的角色:', to.meta.roles);
|
||||
console.log('👤 用户角色:', userRole);
|
||||
console.log('🏢 是否为管理端路由:', to.path.startsWith('/admin'));
|
||||
console.log('✅ 是否有权限:', hasPermission);
|
||||
|
||||
if (!hasPermission) {
|
||||
console.log('❌ 权限不足,准备重定向');
|
||||
|
||||
// 增加重定向计数
|
||||
redirectCount++;
|
||||
|
||||
// 防止无限重定向
|
||||
if (redirectCount > MAX_REDIRECTS) {
|
||||
console.log('⚠️ 重定向次数过多,强制跳转到首页');
|
||||
redirectCount = 0;
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止无限重定向:检查是否已经在重定向过程中
|
||||
if (to.name === 'Admin' || to.name === 'UserDashboard') {
|
||||
console.log('⚠️ 检测到重定向循环,强制跳转到首页');
|
||||
redirectCount = 0;
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有权限,根据用户角色重定向到对应首页
|
||||
// 只有normal_user角色跳转到用户端,其他角色(包括未定义的)都跳转到管理端
|
||||
if (userRole === 'normal_user') {
|
||||
console.log('➡️ 重定向到用户端首页');
|
||||
next({ name: "UserDashboard" });
|
||||
} else {
|
||||
console.log('➡️ 重定向到管理端首页 (角色:', userRole || '未定义', ')');
|
||||
next({ name: "Admin" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 成功通过所有检查,重置重定向计数器
|
||||
redirectCount = 0;
|
||||
next();
|
||||
});
|
||||
|
||||
// 路由错误处理
|
||||
router.onError((error) => {
|
||||
console.error("路由错误:", error);
|
||||
});
|
||||
|
||||
// 输出路由信息
|
||||
logRouteInfo();
|
||||
|
||||
export default router;
|
||||
194
hertz_server_django_ui/src/router/user_menu_ai.ts
Normal file
194
hertz_server_django_ui/src/router/user_menu_ai.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { getEnabledModuleKeys, isModuleEnabled } from '@/config/hertz_modules'
|
||||
|
||||
export interface UserMenuConfig {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
path: string
|
||||
component: string
|
||||
children?: UserMenuConfig[]
|
||||
disabled?: boolean
|
||||
meta?: {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
roles?: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
moduleKey?: string
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
path?: string
|
||||
children?: MenuItem[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const userMenuConfigs: UserMenuConfig[] = [
|
||||
{ key: 'dashboard', label: '首页', icon: 'DashboardOutlined', path: '/dashboard', component: 'index.vue', meta: { title: '用户首页', requiresAuth: true } },
|
||||
{ key: 'profile', label: '个人信息', icon: 'UserOutlined', path: '/user/profile', component: 'Profile.vue', meta: { title: '个人信息', requiresAuth: true, hideInMenu: true } },
|
||||
// { key: 'documents', label: '文档管理', icon: 'FileTextOutlined', path: '/user/documents', component: 'Documents.vue', meta: { title: '文档管理', requiresAuth: true } },
|
||||
{ key: 'system-monitor', label: '系统监控', icon: 'DashboardOutlined', path: '/user/system-monitor', component: 'SystemMonitor.vue', meta: { title: '系统监控', requiresAuth: true }, 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')),
|
||||
'Documents.vue': defineAsyncComponent(() => import('@/views/user_pages/Documents.vue')),
|
||||
'Messages.vue': defineAsyncComponent(() => import('@/views/user_pages/Messages.vue')),
|
||||
'SystemMonitor.vue': defineAsyncComponent(() => import('@/views/user_pages/SystemMonitor.vue')),
|
||||
'AiChat.vue': defineAsyncComponent(() => import('@/views/user_pages/AiChat.vue')),
|
||||
'YoloDetection.vue': defineAsyncComponent(() => import('@/views/user_pages/YoloDetection.vue')),
|
||||
'LiveDetection.vue': defineAsyncComponent(() => import('@/views/user_pages/LiveDetection.vue')),
|
||||
'DetectionHistory.vue': defineAsyncComponent(() => import('@/views/user_pages/DetectionHistory.vue')),
|
||||
'AlertCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/AlertCenter.vue')),
|
||||
'NoticeCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/NoticeCenter.vue')),
|
||||
'ArticleCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/ArticleCenter.vue')),
|
||||
'KbCenter.vue': defineAsyncComponent(() => import('@/views/user_pages/KbCenter.vue')),
|
||||
}
|
||||
|
||||
export const userMenuItems: MenuItem[] = effectiveUserMenuConfigs.map(config => ({
|
||||
key: config.key,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
path: config.path,
|
||||
disabled: config.disabled,
|
||||
children: config.children?.map(child => ({ key: child.key, label: child.label, icon: child.icon, path: child.path, disabled: child.disabled }))
|
||||
}))
|
||||
|
||||
const componentMap: Record<string, () => Promise<any>> = {
|
||||
'index.vue': () => import('@/views/user_pages/index.vue'),
|
||||
'Profile.vue': () => import('@/views/user_pages/Profile.vue'),
|
||||
'Documents.vue': () => import('@/views/user_pages/Documents.vue'),
|
||||
'Messages.vue': () => import('@/views/user_pages/Messages.vue'),
|
||||
'SystemMonitor.vue': () => import('@/views/user_pages/SystemMonitor.vue'),
|
||||
'AiChat.vue': () => import('@/views/user_pages/AiChat.vue'),
|
||||
'YoloDetection.vue': () => import('@/views/user_pages/YoloDetection.vue'),
|
||||
'LiveDetection.vue': () => import('@/views/user_pages/LiveDetection.vue'),
|
||||
'DetectionHistory.vue': () => import('@/views/user_pages/DetectionHistory.vue'),
|
||||
'AlertCenter.vue': () => import('@/views/user_pages/AlertCenter.vue'),
|
||||
'NoticeCenter.vue': () => import('@/views/user_pages/NoticeCenter.vue'),
|
||||
'ArticleCenter.vue': () => import('@/views/user_pages/ArticleCenter.vue'),
|
||||
'KbCenter.vue': () => import('@/views/user_pages/KbCenter.vue'),
|
||||
}
|
||||
|
||||
const baseRoutes: RouteRecordRaw[] = effectiveUserMenuConfigs.map(config => {
|
||||
const route: RouteRecordRaw = {
|
||||
path: config.path,
|
||||
name: `User${config.key.charAt(0).toUpperCase() + config.key.slice(1)}`,
|
||||
component: componentMap[config.component] || (() => import('@/views/NotFound.vue')),
|
||||
meta: { title: config.meta?.title || config.label, requiresAuth: config.meta?.requiresAuth ?? true, ...config.meta }
|
||||
}
|
||||
if (config.children && config.children.length > 0) {
|
||||
route.children = config.children.map(child => ({
|
||||
path: child.path,
|
||||
name: `User${child.key.charAt(0).toUpperCase() + child.key.slice(1)}`,
|
||||
component: componentMap[child.component] || (() => import('@/views/NotFound.vue')),
|
||||
meta: { title: child.meta?.title || child.label, requiresAuth: child.meta?.requiresAuth ?? true, ...child.meta }
|
||||
}))
|
||||
}
|
||||
return route
|
||||
})
|
||||
|
||||
// 文章详情独立页面(不在菜单展示)
|
||||
const knowledgeDetailRoute: RouteRecordRaw = {
|
||||
path: '/user/knowledge/:id',
|
||||
name: 'UserKnowledgeDetail',
|
||||
component: () => import('@/views/user_pages/ArticleDetail.vue'),
|
||||
meta: { title: '文章详情', requiresAuth: true, hideInMenu: true }
|
||||
}
|
||||
|
||||
export const userRoutes: RouteRecordRaw[] = [...baseRoutes, knowledgeDetailRoute]
|
||||
|
||||
export function getMenuPath(menuKey: string): string {
|
||||
const findPath = (items: MenuItem[], key: string): string | null => {
|
||||
for (const item of items) {
|
||||
if (item.key === key && item.path) return item.path
|
||||
if (item.children) {
|
||||
const childPath = findPath(item.children, key)
|
||||
if (childPath) return childPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findPath(userMenuItems, menuKey) || '/dashboard'
|
||||
}
|
||||
|
||||
export function getMenuBreadcrumb(menuKey: string): string[] {
|
||||
const findBreadcrumb = (items: MenuItem[], key: string, path: string[] = []): string[] | null => {
|
||||
for (const item of items) {
|
||||
const currentPath = [...path, item.label]
|
||||
if (item.key === menuKey) return currentPath
|
||||
if (item.children) {
|
||||
const childPath = findBreadcrumb(item.children, key, currentPath)
|
||||
if (childPath) return childPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findBreadcrumb(userMenuItems, menuKey) || ['仪表盘']
|
||||
}
|
||||
|
||||
export const generateComponentMap = () => {
|
||||
const map: Record<string, any> = {}
|
||||
const processConfigs = (configs: UserMenuConfig[]) => {
|
||||
configs.forEach(config => {
|
||||
if (explicitComponentMap[config.component]) {
|
||||
map[config.key] = explicitComponentMap[config.component]
|
||||
} else {
|
||||
map[config.key] = defineAsyncComponent(() => import('@/views/NotFound.vue'))
|
||||
}
|
||||
if (config.children) processConfigs(config.children)
|
||||
})
|
||||
}
|
||||
processConfigs(effectiveUserMenuConfigs)
|
||||
return map
|
||||
}
|
||||
|
||||
export const userComponentMap = generateComponentMap()
|
||||
|
||||
export const getFilteredUserMenuItems = (userRoles: string[], userPermissions: string[]): MenuItem[] => {
|
||||
return effectiveUserMenuConfigs
|
||||
.filter(config => {
|
||||
// 隐藏菜单中不显示的项(如个人信息,只在用户下拉菜单中显示)
|
||||
if (config.meta?.hideInMenu) return false
|
||||
if (!config.meta?.roles || config.meta.roles.length === 0) return true
|
||||
return config.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
})
|
||||
.map(config => ({
|
||||
key: config.key,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
path: config.path,
|
||||
disabled: config.disabled,
|
||||
children: config.children?.filter(child => {
|
||||
if (!child.meta?.roles || child.meta.roles.length === 0) return true
|
||||
return child.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
}).map(child => ({ key: child.key, label: child.label, icon: child.icon, path: child.path, disabled: child.disabled }))
|
||||
}))
|
||||
}
|
||||
|
||||
export const hasUserMenuPermission = (menuKey: string, userRoles: string[]): boolean => {
|
||||
const menuConfig = userMenuConfigs.find(config => config.key === menuKey)
|
||||
if (!menuConfig) return false
|
||||
if (!menuConfig.meta?.roles || menuConfig.meta.roles.length === 0) return true
|
||||
return menuConfig.meta.roles.some(requiredRole => userRoles.includes(requiredRole))
|
||||
}
|
||||
98
hertz_server_django_ui/src/stores/hertz_app.ts
Normal file
98
hertz_server_django_ui/src/stores/hertz_app.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { i18n } from '@/locales'
|
||||
|
||||
// 主题类型
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
// 语言类型
|
||||
export type Language = 'zh-CN' | 'en-US'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 状态
|
||||
const theme = ref<Theme>('light')
|
||||
const language = ref<Language>('zh-CN')
|
||||
const collapsed = ref<boolean>(false)
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
// 计算属性
|
||||
const isDark = computed(() => {
|
||||
if (theme.value === 'auto') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return theme.value === 'dark'
|
||||
})
|
||||
|
||||
const currentLanguage = computed(() => language.value)
|
||||
|
||||
// 方法
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
|
||||
// 应用主题到HTML
|
||||
const html = document.documentElement
|
||||
if (newTheme === 'dark' || (newTheme === 'auto' && isDark.value)) {
|
||||
html.classList.add('dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
const setLanguage = (newLanguage: Language) => {
|
||||
language.value = newLanguage
|
||||
localStorage.setItem('language', newLanguage)
|
||||
|
||||
// 设置i18n语言
|
||||
i18n.global.locale.value = newLanguage
|
||||
}
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
const setLoading = (state: boolean) => {
|
||||
loading.value = state
|
||||
}
|
||||
|
||||
const initAppSettings = () => {
|
||||
// 从本地存储恢复设置
|
||||
const savedTheme = localStorage.getItem('theme') as Theme
|
||||
const savedLanguage = localStorage.getItem('language') as Language
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme)
|
||||
}
|
||||
|
||||
if (savedLanguage) {
|
||||
setLanguage(savedLanguage)
|
||||
} else {
|
||||
// 根据浏览器语言自动设置
|
||||
const browserLang = navigator.language
|
||||
if (browserLang.startsWith('zh')) {
|
||||
setLanguage('zh-CN')
|
||||
} else {
|
||||
setLanguage('en-US')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
theme,
|
||||
language,
|
||||
collapsed,
|
||||
loading,
|
||||
|
||||
// 计算属性
|
||||
isDark,
|
||||
currentLanguage,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
setLanguage,
|
||||
toggleCollapsed,
|
||||
setLoading,
|
||||
initAppSettings,
|
||||
}
|
||||
})
|
||||
101
hertz_server_django_ui/src/stores/hertz_theme.ts
Normal file
101
hertz_server_django_ui/src/stores/hertz_theme.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 主题配置接口
|
||||
export interface ThemeConfig {
|
||||
// 导航栏
|
||||
headerBg: string
|
||||
headerText: string
|
||||
headerBorder: string
|
||||
|
||||
// 背景
|
||||
pageBg: string
|
||||
contentBg: string
|
||||
|
||||
// 组件背景
|
||||
cardBg: string
|
||||
cardBorder: string
|
||||
|
||||
// 主色调
|
||||
primaryColor: string
|
||||
textPrimary: string
|
||||
textSecondary: string
|
||||
}
|
||||
|
||||
// 默认主题
|
||||
const defaultTheme: ThemeConfig = {
|
||||
headerBg: '#ffffff',
|
||||
headerText: '#111827',
|
||||
headerBorder: '#e5e7eb',
|
||||
pageBg: '#ffffff',
|
||||
contentBg: '#ffffff',
|
||||
cardBg: '#ffffff',
|
||||
cardBorder: '#e5e7eb',
|
||||
primaryColor: '#2563eb',
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#6b7280',
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const theme = ref<ThemeConfig>({ ...defaultTheme })
|
||||
|
||||
// 从 localStorage 加载主题
|
||||
const loadTheme = () => {
|
||||
const savedTheme = localStorage.getItem('customTheme')
|
||||
if (savedTheme) {
|
||||
try {
|
||||
theme.value = { ...defaultTheme, ...JSON.parse(savedTheme) }
|
||||
applyTheme(theme.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to load theme:', e)
|
||||
}
|
||||
} else {
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
const applyTheme = (config: ThemeConfig) => {
|
||||
const root = document.documentElement
|
||||
|
||||
// 设置 CSS 变量
|
||||
root.style.setProperty('--theme-header-bg', config.headerBg)
|
||||
root.style.setProperty('--theme-header-text', config.headerText)
|
||||
root.style.setProperty('--theme-header-border', config.headerBorder)
|
||||
root.style.setProperty('--theme-page-bg', config.pageBg)
|
||||
root.style.setProperty('--theme-content-bg', config.contentBg)
|
||||
root.style.setProperty('--theme-card-bg', config.cardBg)
|
||||
root.style.setProperty('--theme-card-border', config.cardBorder)
|
||||
root.style.setProperty('--theme-primary', config.primaryColor)
|
||||
root.style.setProperty('--theme-text-primary', config.textPrimary)
|
||||
root.style.setProperty('--theme-text-secondary', config.textSecondary)
|
||||
}
|
||||
|
||||
// 更新主题
|
||||
const updateTheme = (newTheme: Partial<ThemeConfig>) => {
|
||||
theme.value = { ...theme.value, ...newTheme }
|
||||
applyTheme(theme.value)
|
||||
localStorage.setItem('customTheme', JSON.stringify(theme.value))
|
||||
}
|
||||
|
||||
// 重置主题
|
||||
const resetTheme = () => {
|
||||
theme.value = { ...defaultTheme }
|
||||
applyTheme(theme.value)
|
||||
localStorage.removeItem('customTheme')
|
||||
}
|
||||
|
||||
// 监听主题变化,自动应用
|
||||
watch(theme, (newTheme) => {
|
||||
applyTheme(newTheme)
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
theme,
|
||||
loadTheme,
|
||||
updateTheme,
|
||||
resetTheme,
|
||||
applyTheme,
|
||||
}
|
||||
})
|
||||
|
||||
261
hertz_server_django_ui/src/stores/hertz_user.ts
Normal file
261
hertz_server_django_ui/src/stores/hertz_user.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { request } from '@/utils/hertz_request'
|
||||
import { changePassword } from '@/api/password'
|
||||
import type { ChangePasswordParams } from '@/api/password'
|
||||
import { roleApi } from '@/api/role'
|
||||
import { initializeMenuMapping } from '@/utils/menu_mapping'
|
||||
import { logoutUser } from '@/api/auth'
|
||||
import { hasModuleSelection } from '@/config/hertz_modules'
|
||||
|
||||
// 用户信息接口
|
||||
interface UserInfo {
|
||||
user_id: number
|
||||
username: string
|
||||
email: string
|
||||
phone?: string
|
||||
real_name?: string
|
||||
avatar?: string
|
||||
roles: Array<{
|
||||
role_id: number
|
||||
role_name: string
|
||||
role_code: string
|
||||
}>
|
||||
permissions: string[]
|
||||
menu_permissions?: number[] // 用户拥有的菜单权限ID列表
|
||||
}
|
||||
|
||||
// 登录参数接口
|
||||
interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const token = ref<string>('')
|
||||
const isLoggedIn = ref<boolean>(false)
|
||||
const loading = ref<boolean>(false)
|
||||
const userMenuPermissions = ref<number[]>([]) // 用户菜单权限ID列表
|
||||
|
||||
// 计算属性
|
||||
const hasPermission = computed(() => (permission: string) => {
|
||||
return userInfo.value?.permissions?.includes(permission) || false
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
const userRole = userInfo.value?.roles?.[0]?.role_code
|
||||
return userRole === 'admin' || userRole === 'system_admin' || userRole === 'super_admin'
|
||||
})
|
||||
|
||||
// 方法
|
||||
const login = async (params: LoginParams) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await request.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
user_info: UserInfo
|
||||
}>('/api/auth/login/', params)
|
||||
|
||||
token.value = response.access_token
|
||||
userInfo.value = response.user_info
|
||||
isLoggedIn.value = true
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', response.access_token)
|
||||
if (params.remember) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.user_info))
|
||||
}
|
||||
|
||||
// 获取用户菜单权限(模板模式首次运行时跳过)
|
||||
const isTemplateMode = import.meta.env.VITE_TEMPLATE_SETUP_MODE === 'true'
|
||||
if (!isTemplateMode || hasModuleSelection()) {
|
||||
await fetchUserMenuPermissions()
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用封装好的退出登录接口
|
||||
await logoutUser()
|
||||
|
||||
// 清除状态
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
isLoggedIn.value = false
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
// 即使请求失败也要清除本地状态
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateUserInfo = async (info: Partial<UserInfo>) => {
|
||||
try {
|
||||
const response = await request.put<UserInfo>('/user/profile', info)
|
||||
|
||||
userInfo.value = { ...userInfo.value, ...response }
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('更新用户信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = async () => {
|
||||
console.log('🔍 检查用户认证状态...')
|
||||
|
||||
const savedToken = localStorage.getItem('token')
|
||||
const savedUserInfo = localStorage.getItem('userInfo')
|
||||
|
||||
console.log('💾 localStorage中的token:', savedToken ? '存在' : '不存在')
|
||||
console.log('💾 localStorage中的userInfo:', savedUserInfo ? '存在' : '不存在')
|
||||
|
||||
if (savedToken && savedUserInfo) {
|
||||
try {
|
||||
const parsedUserInfo = JSON.parse(savedUserInfo)
|
||||
token.value = savedToken
|
||||
userInfo.value = parsedUserInfo
|
||||
isLoggedIn.value = true
|
||||
|
||||
console.log('✅ 用户状态恢复成功')
|
||||
console.log('👤 恢复的用户信息:', parsedUserInfo)
|
||||
console.log('🔐 登录状态:', isLoggedIn.value)
|
||||
|
||||
// 获取用户菜单权限(模板模式首次运行时跳过)
|
||||
const isTemplateMode = import.meta.env.VITE_TEMPLATE_SETUP_MODE === 'true'
|
||||
if (!isTemplateMode || hasModuleSelection()) {
|
||||
await fetchUserMenuPermissions()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 解析用户信息失败:', error)
|
||||
clearAuth()
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 没有找到保存的认证信息')
|
||||
}
|
||||
}
|
||||
|
||||
const clearAuth = () => {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
isLoggedIn.value = false
|
||||
userMenuPermissions.value = []
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
|
||||
const updatePassword = async (params: ChangePasswordParams) => {
|
||||
try {
|
||||
await changePassword(params)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户菜单权限
|
||||
const fetchUserMenuPermissions = async () => {
|
||||
if (!userInfo.value?.roles?.length) {
|
||||
userMenuPermissions.value = []
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const 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>()
|
||||
|
||||
for (const role of userInfo.value.roles) {
|
||||
try {
|
||||
const response = await roleApi.getRolePermissions(role.role_id)
|
||||
if (response.success) {
|
||||
const menuIds = response.data.list || response.data
|
||||
if (Array.isArray(menuIds)) {
|
||||
menuIds.forEach((menuId: any) => {
|
||||
const id = typeof menuId === 'number' ? menuId : Number(menuId)
|
||||
if (!isNaN(id)) {
|
||||
allMenuPermissions.add(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取角色 ${role.role_name} 的菜单权限失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const permissions = Array.from(allMenuPermissions)
|
||||
userMenuPermissions.value = permissions
|
||||
|
||||
// 同时更新用户信息中的菜单权限
|
||||
if (userInfo.value) {
|
||||
userInfo.value.menu_permissions = permissions
|
||||
}
|
||||
|
||||
// 初始化菜单映射关系
|
||||
await initializeMenuMapping()
|
||||
|
||||
return permissions
|
||||
} catch (error) {
|
||||
console.error('获取用户菜单权限失败:', error)
|
||||
userMenuPermissions.value = []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
token,
|
||||
isLoggedIn,
|
||||
loading,
|
||||
userMenuPermissions,
|
||||
|
||||
// 计算属性
|
||||
hasPermission,
|
||||
isAdmin,
|
||||
|
||||
// 方法
|
||||
login,
|
||||
logout,
|
||||
updateUserInfo,
|
||||
checkAuth,
|
||||
clearAuth,
|
||||
updatePassword,
|
||||
fetchUserMenuPermissions,
|
||||
}
|
||||
})
|
||||
422
hertz_server_django_ui/src/styles/index.scss
Normal file
422
hertz_server_django_ui/src/styles/index.scss
Normal file
@@ -0,0 +1,422 @@
|
||||
// 全局样式入口文件
|
||||
@use 'variables' as *;
|
||||
@use 'sass:color';
|
||||
|
||||
// 全局样式
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 按钮样式
|
||||
.btn {
|
||||
@include transition(all);
|
||||
padding: $spacing-3 $spacing-6;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-primary;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: $font-size-sm;
|
||||
line-height: 1.5;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
@include button-style($primary-color, white);
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: $bg-primary;
|
||||
color: $gray-700;
|
||||
border-color: $gray-300;
|
||||
|
||||
&:hover {
|
||||
background: $gray-50;
|
||||
border-color: $primary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
@include button-style($success-color, white);
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
@include button-style($error-color, white);
|
||||
}
|
||||
|
||||
&.btn-warning {
|
||||
@include button-style($warning-color, white);
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片样式
|
||||
.card {
|
||||
@include card-style;
|
||||
padding: $spacing-6;
|
||||
margin-bottom: $spacing-4;
|
||||
}
|
||||
|
||||
// 表单样式
|
||||
.form-item {
|
||||
margin-bottom: $spacing-4;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: $spacing-2;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: $spacing-3;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-sm;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 布局辅助类
|
||||
.flex-center {
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
// 间距辅助类
|
||||
.m-0 { margin: $spacing-0; }
|
||||
.m-1 { margin: $spacing-1; }
|
||||
.m-2 { margin: $spacing-2; }
|
||||
.m-3 { margin: $spacing-3; }
|
||||
.m-4 { margin: $spacing-4; }
|
||||
.m-5 { margin: $spacing-5; }
|
||||
.m-6 { margin: $spacing-6; }
|
||||
.m-8 { margin: $spacing-8; }
|
||||
|
||||
.p-0 { padding: $spacing-0; }
|
||||
.p-1 { padding: $spacing-1; }
|
||||
.p-2 { padding: $spacing-2; }
|
||||
.p-3 { padding: $spacing-3; }
|
||||
.p-4 { padding: $spacing-4; }
|
||||
.p-5 { padding: $spacing-5; }
|
||||
.p-6 { padding: $spacing-6; }
|
||||
.p-8 { padding: $spacing-8; }
|
||||
|
||||
// ==================== 全局弹窗美化样式 - 苹果风格 ====================
|
||||
// 弹窗遮罩层
|
||||
.ant-modal-mask {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
// 弹窗容器
|
||||
.ant-modal-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 统一按钮主题 - 苹果风格
|
||||
.ant-btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0 20px;
|
||||
height: 40px;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
border-width: 0.5px;
|
||||
|
||||
&.ant-btn-default {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
color: #1d1d1f;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
&:active { transform: translateY(0); }
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous:not(.ant-btn-link) {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-link {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.ant-btn-sm {
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗内容 - 苹果风格
|
||||
.ant-modal {
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.ant-modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
animation: modalSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
// 弹窗头部
|
||||
.ant-modal-header {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
padding: 24px 28px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
|
||||
.ant-modal-title {
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
top: 24px;
|
||||
right: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 16px;
|
||||
color: #86868b;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover { color: #1d1d1f; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗主体
|
||||
.ant-modal-body {
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 弹窗底部
|
||||
.ant-modal-footer {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
padding: 20px 28px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 10px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:not(.ant-btn-primary) {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #1d1d1f;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.16);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
&:active { transform: translateY(0); }
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单元素美化
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 500;
|
||||
color: #1d1d1f;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-select-selector,
|
||||
.ant-input-number,
|
||||
.ant-picker,
|
||||
.ant-textarea,
|
||||
.ant-tree-select-selector {
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover { border-color: #3b82f6; background: rgba(255, 255, 255, 1); }
|
||||
&:focus,
|
||||
&.ant-input-focused,
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&.ant-picker-focused { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); background: rgba(255, 255, 255, 1); }
|
||||
}
|
||||
|
||||
.ant-input-number { width: 100%; }
|
||||
|
||||
.ant-radio-group {
|
||||
.ant-radio-button-wrapper {
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
&:hover { border-color: #3b82f6; }
|
||||
&.ant-radio-button-wrapper-checked { background: #3b82f6; border-color: #3b82f6; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
}
|
||||
}
|
||||
|
||||
.ant-switch { background: rgba(0, 0, 0, 0.25); &.ant-switch-checked { background: #10b981; } }
|
||||
|
||||
// 表格在弹窗中的样式
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
.ant-table-thead > tr > th {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
&:hover > td { background: rgba(0, 0, 0, 0.02); }
|
||||
> td { padding: 16px; border-bottom: 0.5px solid rgba(0, 0, 0, 0.06); color: #1d1d1f; }
|
||||
}
|
||||
}
|
||||
|
||||
// 标签样式
|
||||
.ant-tag { border-radius: 6px; font-weight: 500; padding: 2px 10px; border: 0.5px solid currentColor; opacity: 0.8; }
|
||||
|
||||
// 描述列表样式
|
||||
.ant-descriptions {
|
||||
.ant-descriptions-item-label { font-weight: 500; color: #1d1d1f; background: rgba(0, 0, 0, 0.02); }
|
||||
.ant-descriptions-item-content { color: #86868b; }
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗动画
|
||||
@keyframes modalSlideIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
// 响应式优化
|
||||
@media (max-width: 768px) {
|
||||
.ant-modal {
|
||||
.ant-modal-content { border-radius: 16px; }
|
||||
.ant-modal-header { padding: 20px 20px; border-radius: 16px 16px 0 0; .ant-modal-title { font-size: 18px; } }
|
||||
.ant-modal-body { padding: 20px; }
|
||||
.ant-modal-footer { padding: 16px 20px; border-radius: 0 0 16px 16px; }
|
||||
}
|
||||
}
|
||||
124
hertz_server_django_ui/src/styles/variables.scss
Normal file
124
hertz_server_django_ui/src/styles/variables.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
// 全局变量文件 - 简约现代风格
|
||||
|
||||
// 颜色系统
|
||||
$primary-color: #2563eb;
|
||||
$primary-light: #3b82f6;
|
||||
$primary-dark: #1d4ed8;
|
||||
$success-color: #10b981;
|
||||
$warning-color: #f59e0b;
|
||||
$error-color: #ef4444;
|
||||
$info-color: #06b6d4;
|
||||
|
||||
// 中性色系统
|
||||
$gray-50: #f9fafb;
|
||||
$gray-100: #f3f4f6;
|
||||
$gray-200: #e5e7eb;
|
||||
$gray-300: #d1d5db;
|
||||
$gray-400: #9ca3af;
|
||||
$gray-500: #6b7280;
|
||||
$gray-600: #4b5563;
|
||||
$gray-700: #374151;
|
||||
$gray-800: #1f2937;
|
||||
$gray-900: #111827;
|
||||
|
||||
// 背景色
|
||||
$bg-primary: #ffffff;
|
||||
$bg-secondary: #f9fafb;
|
||||
$bg-tertiary: #f3f4f6;
|
||||
|
||||
// 字体大小
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 14px;
|
||||
$font-size-base: 16px;
|
||||
$font-size-lg: 18px;
|
||||
$font-size-xl: 20px;
|
||||
$font-size-2xl: 24px;
|
||||
$font-size-3xl: 30px;
|
||||
$font-size-4xl: 36px;
|
||||
|
||||
// 间距系统 - 4px基础单位
|
||||
$spacing-0: 0;
|
||||
$spacing-1: 4px;
|
||||
$spacing-2: 8px;
|
||||
$spacing-3: 12px;
|
||||
$spacing-4: 16px;
|
||||
$spacing-5: 20px;
|
||||
$spacing-6: 24px;
|
||||
$spacing-8: 32px;
|
||||
$spacing-10: 40px;
|
||||
$spacing-12: 48px;
|
||||
$spacing-16: 64px;
|
||||
$spacing-20: 80px;
|
||||
|
||||
// 圆角系统
|
||||
$radius-none: 0;
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 6px;
|
||||
$radius-lg: 8px;
|
||||
$radius-xl: 12px;
|
||||
$radius-2xl: 16px;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// 阴影系统
|
||||
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
// 过渡时间
|
||||
$transition-fast: 0.15s;
|
||||
$transition-normal: 0.2s;
|
||||
$transition-slow: 0.3s;
|
||||
|
||||
// 混合器
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@mixin text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin box-shadow($shadow: $shadow-md) {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
@mixin transition($property: all, $duration: $transition-normal) {
|
||||
transition: #{$property} #{$duration} ease;
|
||||
}
|
||||
|
||||
@mixin card-style {
|
||||
background: $bg-primary;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1px solid $gray-200;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-style($bg-color: $primary-color, $text-color: white) {
|
||||
background: $bg-color;
|
||||
color: $text-color;
|
||||
border: 1px solid $bg-color;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-3 $spacing-6;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: darken($bg-color, 8%);
|
||||
border-color: darken($bg-color, 8%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: darken($bg-color, 12%);
|
||||
}
|
||||
}
|
||||
13
hertz_server_django_ui/src/types/env.d.ts
vendored
Normal file
13
hertz_server_django_ui/src/types/env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_VERSION: string
|
||||
readonly VITE_DEV_SERVER_HOST: string
|
||||
readonly VITE_DEV_SERVER_PORT: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
182
hertz_server_django_ui/src/types/hertz_types.ts
Normal file
182
hertz_server_django_ui/src/types/hertz_types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success?: boolean
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
sortBy?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PageResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
role: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive' | 'banned'
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
// 菜单相关类型
|
||||
export interface MenuItem {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
icon?: string
|
||||
children?: MenuItem[]
|
||||
permission?: string
|
||||
hidden?: boolean
|
||||
meta?: {
|
||||
title: string
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 表格相关类型
|
||||
export interface TableColumn<T = any> {
|
||||
key: string
|
||||
title: string
|
||||
width?: number
|
||||
fixed?: 'left' | 'right'
|
||||
sortable?: boolean
|
||||
render?: (record: T, index: number) => any
|
||||
}
|
||||
|
||||
export interface TableProps<T = any> {
|
||||
data: T[]
|
||||
columns: TableColumn<T>[]
|
||||
loading?: boolean
|
||||
pagination?: {
|
||||
current: number
|
||||
pageSize: number
|
||||
total: number
|
||||
showSizeChanger?: boolean
|
||||
showQuickJumper?: boolean
|
||||
}
|
||||
rowSelection?: {
|
||||
selectedRowKeys: (string | number)[]
|
||||
onChange: (selectedRowKeys: (string | number)[], selectedRows: T[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
// 表单相关类型
|
||||
export interface FormField {
|
||||
name: string
|
||||
label: string
|
||||
type: 'input' | 'select' | 'textarea' | 'date' | 'switch' | 'radio' | 'checkbox'
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
options?: { label: string; value: any }[]
|
||||
rules?: any[]
|
||||
}
|
||||
|
||||
export interface FormProps {
|
||||
fields: FormField[]
|
||||
initialValues?: Record<string, any>
|
||||
onSubmit: (values: Record<string, any>) => Promise<void>
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// 弹窗相关类型
|
||||
export interface ModalProps {
|
||||
title: string
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onOk?: () => void
|
||||
width?: number
|
||||
children: any
|
||||
}
|
||||
|
||||
// 消息相关类型
|
||||
export type MessageType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface MessageConfig {
|
||||
type: MessageType
|
||||
content: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
// 主题相关类型
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
// 语言相关类型
|
||||
export type Language = 'zh-CN' | 'en-US'
|
||||
|
||||
// 路由相关类型
|
||||
export interface RouteMeta {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
hidden?: boolean
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// 组件属性类型
|
||||
export interface ComponentProps {
|
||||
className?: string
|
||||
style?: Record<string, any>
|
||||
children?: any
|
||||
}
|
||||
|
||||
// 工具函数类型
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
|
||||
}
|
||||
|
||||
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
||||
|
||||
// API 相关类型
|
||||
export interface RequestConfig {
|
||||
showLoading?: boolean
|
||||
showError?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
// 文件相关类型
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url?: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
export interface UploadProps {
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
maxSize?: number
|
||||
onUpload: (files: File[]) => Promise<void>
|
||||
onRemove?: (file: FileInfo) => void
|
||||
}
|
||||
70
hertz_server_django_ui/src/utils/hertz_captcha.ts
Normal file
70
hertz_server_django_ui/src/utils/hertz_captcha.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { generateCaptcha, refreshCaptcha, type CaptchaResponse, type CaptchaRefreshResponse } from '@/api/captcha'
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 验证码组合式函数
|
||||
*/
|
||||
export function useCaptcha() {
|
||||
// 验证码数据
|
||||
const captchaData: Ref<CaptchaResponse | null> = ref(null)
|
||||
|
||||
// 加载状态
|
||||
const captchaLoading: Ref<boolean> = ref(false)
|
||||
|
||||
// 错误信息
|
||||
const captchaError: Ref<string | null> = ref(null)
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
const handleGenerateCaptcha = async (): Promise<void> => {
|
||||
try {
|
||||
captchaLoading.value = true
|
||||
captchaError.value = null
|
||||
|
||||
const response = await generateCaptcha()
|
||||
captchaData.value = response
|
||||
} catch (error) {
|
||||
console.error('生成验证码失败:', error)
|
||||
captchaError.value = error instanceof Error ? error.message : '生成验证码失败'
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const handleRefreshCaptcha = async (): Promise<void> => {
|
||||
try {
|
||||
captchaLoading.value = true
|
||||
captchaError.value = null
|
||||
|
||||
// 检查是否有当前验证码ID
|
||||
if (!captchaData.value?.captcha_id) {
|
||||
console.warn('没有当前验证码ID,将生成新的验证码')
|
||||
await handleGenerateCaptcha()
|
||||
return
|
||||
}
|
||||
|
||||
const response = await refreshCaptcha(captchaData.value.captcha_id)
|
||||
captchaData.value = response
|
||||
} catch (error) {
|
||||
console.error('刷新验证码失败:', error)
|
||||
captchaError.value = error instanceof Error ? error.message : '刷新验证码失败'
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
captchaData,
|
||||
captchaLoading,
|
||||
captchaError,
|
||||
generateCaptcha: handleGenerateCaptcha,
|
||||
refreshCaptcha: handleRefreshCaptcha
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { CaptchaResponse, CaptchaRefreshResponse }
|
||||
87
hertz_server_django_ui/src/utils/hertz_env.ts
Normal file
87
hertz_server_django_ui/src/utils/hertz_env.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 环境变量检查工具
|
||||
* 用于在开发环境中检查环境变量配置是否正确
|
||||
*/
|
||||
|
||||
// 检查环境变量配置
|
||||
export const checkEnvironmentVariables = () => {
|
||||
console.log('🔧 环境变量检查')
|
||||
|
||||
// 在Vite中,环境变量可能通过define选项直接定义
|
||||
// 或者通过import.meta.env读取
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'Hertz Admin'
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
|
||||
|
||||
// 检查必需的环境变量
|
||||
const requiredVars = [
|
||||
{ key: 'VITE_API_BASE_URL', value: apiBaseUrl },
|
||||
{ key: 'VITE_APP_TITLE', value: appTitle },
|
||||
{ key: 'VITE_APP_VERSION', value: appVersion },
|
||||
]
|
||||
|
||||
requiredVars.forEach(({ key, value }) => {
|
||||
if (value) {
|
||||
console.log(`✅ ${key}: ${value}`)
|
||||
} else {
|
||||
console.warn(`❌ ${key}: 未设置`)
|
||||
}
|
||||
})
|
||||
|
||||
// 检查可选的环境变量
|
||||
const devServerHost = import.meta.env.VITE_DEV_SERVER_HOST || 'localhost'
|
||||
const devServerPort = import.meta.env.VITE_DEV_SERVER_PORT || '3000'
|
||||
|
||||
const optionalVars = [
|
||||
{ key: 'VITE_DEV_SERVER_HOST', value: devServerHost },
|
||||
{ key: 'VITE_DEV_SERVER_PORT', value: devServerPort },
|
||||
]
|
||||
|
||||
optionalVars.forEach(({ key, value }) => {
|
||||
if (value) {
|
||||
console.log(`ℹ️ ${key}: ${value}`)
|
||||
} else {
|
||||
console.log(`➖ ${key}: 未设置(使用默认值)`)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('🎉 环境变量检查完成')
|
||||
}
|
||||
|
||||
// 验证环境变量是否有效
|
||||
export const validateEnvironment = () => {
|
||||
// 检查API基础地址
|
||||
if (!import.meta.env.VITE_API_BASE_URL) {
|
||||
console.warn('⚠️ VITE_API_BASE_URL 未设置,将使用默认值')
|
||||
}
|
||||
|
||||
// 检查应用配置
|
||||
if (!import.meta.env.VITE_APP_TITLE) {
|
||||
console.warn('⚠️ VITE_APP_TITLE 未设置,将使用默认值')
|
||||
}
|
||||
|
||||
if (!import.meta.env.VITE_APP_VERSION) {
|
||||
console.warn('⚠️ VITE_APP_VERSION 未设置,将使用默认值')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API基础地址
|
||||
export const getApiBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
// 获取应用配置
|
||||
export const getAppConfig = () => {
|
||||
return {
|
||||
title: import.meta.env.VITE_APP_TITLE || 'Hertz Admin',
|
||||
version: import.meta.env.VITE_APP_VERSION || '1.0.0',
|
||||
apiBaseUrl: getApiBaseUrl(),
|
||||
devServerHost: import.meta.env.VITE_DEV_SERVER_HOST || 'localhost',
|
||||
devServerPort: import.meta.env.VITE_DEV_SERVER_PORT || '3000',
|
||||
}
|
||||
}
|
||||
375
hertz_server_django_ui/src/utils/hertz_error_handler.ts
Normal file
375
hertz_server_django_ui/src/utils/hertz_error_handler.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 错误类型枚举
|
||||
export enum ErrorType {
|
||||
// 网络错误
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
|
||||
// 认证错误
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
||||
TOKEN_INVALID = 'TOKEN_INVALID',
|
||||
|
||||
// 权限错误
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
|
||||
// 业务错误
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
BUSINESS_ERROR = 'BUSINESS_ERROR',
|
||||
|
||||
// 系统错误
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
}
|
||||
|
||||
// 错误信息接口
|
||||
export interface ErrorInfo {
|
||||
code: number
|
||||
message: string
|
||||
type: ErrorType
|
||||
details?: any
|
||||
field?: string
|
||||
}
|
||||
|
||||
// 错误处理器类
|
||||
export class HertzErrorHandler {
|
||||
private static instance: HertzErrorHandler
|
||||
private i18n: any
|
||||
|
||||
constructor() {
|
||||
// 在组件中使用时需要传入i18n实例
|
||||
}
|
||||
|
||||
static getInstance(): HertzErrorHandler {
|
||||
if (!HertzErrorHandler.instance) {
|
||||
HertzErrorHandler.instance = new HertzErrorHandler()
|
||||
}
|
||||
return HertzErrorHandler.instance
|
||||
}
|
||||
|
||||
// 设置i18n实例
|
||||
setI18n(i18n: any) {
|
||||
this.i18n = i18n
|
||||
}
|
||||
|
||||
// 获取翻译文本
|
||||
private t(key: string, fallback?: string): string {
|
||||
if (this.i18n && this.i18n.t) {
|
||||
return this.i18n.t(key)
|
||||
}
|
||||
return fallback || key
|
||||
}
|
||||
|
||||
// 处理HTTP错误
|
||||
handleHttpError(error: any): void {
|
||||
const status = error?.response?.status
|
||||
const data = error?.response?.data
|
||||
|
||||
console.error('🚨 HTTP错误详情:', {
|
||||
status,
|
||||
data,
|
||||
url: error?.config?.url,
|
||||
method: error?.config?.method,
|
||||
requestData: error?.config?.data
|
||||
})
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
this.handleBadRequestError(data)
|
||||
break
|
||||
case 401:
|
||||
this.handleUnauthorizedError(data)
|
||||
break
|
||||
case 403:
|
||||
this.handleForbiddenError(data)
|
||||
break
|
||||
case 404:
|
||||
this.handleNotFoundError(data)
|
||||
break
|
||||
case 422:
|
||||
this.handleValidationError(data)
|
||||
break
|
||||
case 429:
|
||||
this.handleTooManyRequestsError(data)
|
||||
break
|
||||
case 500:
|
||||
this.handleServerError(data)
|
||||
break
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
this.handleServiceUnavailableError(data)
|
||||
break
|
||||
default:
|
||||
this.handleUnknownError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理400错误
|
||||
private handleBadRequestError(data: any): void {
|
||||
const message = data?.message || data?.detail || ''
|
||||
|
||||
// 检查是否是验证码相关错误
|
||||
if (this.isMessageContains(message, ['验证码', 'captcha', 'Captcha'])) {
|
||||
if (this.isMessageContains(message, ['过期', 'expired', 'expire'])) {
|
||||
this.showError(this.t('error.captchaExpired', '验证码已过期,请刷新后重新输入'))
|
||||
} else {
|
||||
this.showError(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是用户名或密码错误
|
||||
if (this.isMessageContains(message, ['用户名', 'username', '密码', 'password', '登录', 'login'])) {
|
||||
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是注册相关错误
|
||||
if (this.isMessageContains(message, ['用户名已存在', 'username exists', 'username already'])) {
|
||||
this.showError(this.t('error.usernameExists', '用户名已存在,请选择其他用户名'))
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isMessageContains(message, ['邮箱已注册', 'email exists', 'email already'])) {
|
||||
this.showError(this.t('error.emailExists', '邮箱已被注册,请使用其他邮箱'))
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isMessageContains(message, ['手机号已注册', 'phone exists', 'phone already'])) {
|
||||
this.showError(this.t('error.phoneExists', '手机号已被注册,请使用其他手机号'))
|
||||
return
|
||||
}
|
||||
|
||||
// 默认400错误处理
|
||||
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
|
||||
}
|
||||
|
||||
// 处理401错误
|
||||
private handleUnauthorizedError(data: any): void {
|
||||
const message = data?.message || data?.detail || ''
|
||||
|
||||
if (this.isMessageContains(message, ['token', 'Token', '令牌', '过期', 'expired'])) {
|
||||
this.showError(this.t('error.tokenExpired', '登录已过期,请重新登录'))
|
||||
// 可以在这里添加自动跳转到登录页的逻辑
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else if (this.isMessageContains(message, ['账户锁定', 'account locked', 'locked'])) {
|
||||
this.showError(this.t('error.accountLocked', '账户已被锁定,请联系管理员'))
|
||||
} else if (this.isMessageContains(message, ['账户禁用', 'account disabled', 'disabled'])) {
|
||||
this.showError(this.t('error.accountDisabled', '账户已被禁用,请联系管理员'))
|
||||
} else {
|
||||
this.showError(this.t('error.loginFailed', '登录失败,请检查用户名和密码'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理403错误
|
||||
private handleForbiddenError(data: any): void {
|
||||
const message = data?.message || data?.detail || ''
|
||||
|
||||
if (this.isMessageContains(message, ['权限不足', 'permission denied', 'access denied'])) {
|
||||
this.showError(this.t('error.permissionDenied', '权限不足,无法执行此操作'))
|
||||
} else {
|
||||
this.showError(this.t('error.accessDenied', '访问被拒绝,您没有执行此操作的权限'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理404错误
|
||||
private handleNotFoundError(data: any): void {
|
||||
const message = data?.message || data?.detail || ''
|
||||
|
||||
if (this.isMessageContains(message, ['用户', 'user'])) {
|
||||
this.showError(this.t('error.userNotFound', '用户不存在或已被删除'))
|
||||
} else if (this.isMessageContains(message, ['部门', 'department'])) {
|
||||
this.showError(this.t('error.departmentNotFound', '部门不存在或已被删除'))
|
||||
} else if (this.isMessageContains(message, ['角色', 'role'])) {
|
||||
this.showError(this.t('error.roleNotFound', '角色不存在或已被删除'))
|
||||
} else {
|
||||
this.showError(this.t('error.404', '页面未找到'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理422验证错误
|
||||
private handleValidationError(data: any): void {
|
||||
console.log('🔍 422验证错误详情:', data)
|
||||
|
||||
// 处理FastAPI风格的验证错误
|
||||
if (data?.detail && Array.isArray(data.detail)) {
|
||||
const errors = data.detail
|
||||
const errorMessages: string[] = []
|
||||
|
||||
errors.forEach((error: any) => {
|
||||
const field = error.loc?.[error.loc.length - 1] || 'unknown'
|
||||
const msg = error.msg || error.message || '验证失败'
|
||||
|
||||
// 根据字段和错误类型提供更具体的提示
|
||||
if (field === 'username') {
|
||||
if (msg.includes('required') || msg.includes('必填')) {
|
||||
errorMessages.push(this.t('error.usernameRequired', '请输入用户名'))
|
||||
} else if (msg.includes('length') || msg.includes('长度')) {
|
||||
errorMessages.push('用户名长度不符合要求')
|
||||
} else {
|
||||
errorMessages.push(`用户名: ${msg}`)
|
||||
}
|
||||
} else if (field === 'password') {
|
||||
if (msg.includes('required') || msg.includes('必填')) {
|
||||
errorMessages.push(this.t('error.passwordRequired', '请输入密码'))
|
||||
} else if (msg.includes('weak') || msg.includes('强度')) {
|
||||
errorMessages.push(this.t('error.passwordTooWeak', '密码强度不足,请包含大小写字母、数字和特殊字符'))
|
||||
} else {
|
||||
errorMessages.push(`密码: ${msg}`)
|
||||
}
|
||||
} else if (field === 'email') {
|
||||
if (msg.includes('format') || msg.includes('格式')) {
|
||||
errorMessages.push(this.t('error.emailFormatError', '邮箱格式不正确,请输入有效的邮箱地址'))
|
||||
} else {
|
||||
errorMessages.push(`邮箱: ${msg}`)
|
||||
}
|
||||
} else if (field === 'phone') {
|
||||
if (msg.includes('format') || msg.includes('格式')) {
|
||||
errorMessages.push(this.t('error.phoneFormatError', '手机号格式不正确,请输入11位手机号'))
|
||||
} else {
|
||||
errorMessages.push(`手机号: ${msg}`)
|
||||
}
|
||||
} else if (field === 'captcha' || field === 'captcha_code') {
|
||||
errorMessages.push(this.t('error.captchaError', '验证码错误,请重新输入(区分大小写)'))
|
||||
} else {
|
||||
errorMessages.push(`${field}: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
this.showError(errorMessages.join(';'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他格式的验证错误
|
||||
if (data?.errors) {
|
||||
const errors = data.errors
|
||||
const errorMessages = []
|
||||
for (const field in errors) {
|
||||
if (errors[field] && Array.isArray(errors[field])) {
|
||||
errorMessages.push(`${field}: ${errors[field].join(', ')}`)
|
||||
} else if (errors[field]) {
|
||||
errorMessages.push(`${field}: ${errors[field]}`)
|
||||
}
|
||||
}
|
||||
if (errorMessages.length > 0) {
|
||||
this.showError(`验证失败: ${errorMessages.join('; ')}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 默认验证错误处理
|
||||
this.showError(data?.message || this.t('error.invalidInput', '输入数据格式不正确'))
|
||||
}
|
||||
|
||||
// 处理429错误(请求过多)
|
||||
private handleTooManyRequestsError(data: any): void {
|
||||
this.showError(this.t('error.loginAttemptsExceeded', '登录尝试次数过多,账户已被临时锁定'))
|
||||
}
|
||||
|
||||
// 处理500错误
|
||||
private handleServerError(data: any): void {
|
||||
this.showError(this.t('error.500', '服务器内部错误,请稍后重试'))
|
||||
}
|
||||
|
||||
// 处理服务不可用错误
|
||||
private handleServiceUnavailableError(data: any): void {
|
||||
this.showError(this.t('error.serviceUnavailable', '服务暂时不可用,请稍后重试'))
|
||||
}
|
||||
|
||||
// 处理网络错误
|
||||
handleNetworkError(error: any): void {
|
||||
if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
|
||||
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
|
||||
} else if (error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
|
||||
this.showError(this.t('error.timeout', '请求超时,请稍后重试'))
|
||||
} else {
|
||||
this.showError(this.t('error.networkError', '网络连接失败,请检查网络设置'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理未知错误
|
||||
private handleUnknownError(error: any): void {
|
||||
console.error('🚨 未知错误:', error)
|
||||
this.showError(this.t('error.operationFailed', '操作失败,请稍后重试'))
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
private showError(msg: string): void {
|
||||
message.error(msg)
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
showSuccess(msg: string): void {
|
||||
message.success(msg)
|
||||
}
|
||||
|
||||
// 显示警告消息
|
||||
showWarning(msg: string): void {
|
||||
message.warning(msg)
|
||||
}
|
||||
|
||||
// 检查消息是否包含指定关键词
|
||||
private isMessageContains(message: string, keywords: string[]): boolean {
|
||||
if (!message) return false
|
||||
const lowerMessage = message.toLowerCase()
|
||||
return keywords.some(keyword => lowerMessage.includes(keyword.toLowerCase()))
|
||||
}
|
||||
|
||||
// 处理业务操作成功
|
||||
handleSuccess(operation: string, customMessage?: string): void {
|
||||
if (customMessage) {
|
||||
this.showSuccess(customMessage)
|
||||
return
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'save':
|
||||
this.showSuccess(this.t('error.saveSuccess', '保存成功'))
|
||||
break
|
||||
case 'delete':
|
||||
this.showSuccess(this.t('error.deleteSuccess', '删除成功'))
|
||||
break
|
||||
case 'update':
|
||||
this.showSuccess(this.t('error.updateSuccess', '更新成功'))
|
||||
break
|
||||
case 'create':
|
||||
this.showSuccess('创建成功')
|
||||
break
|
||||
case 'login':
|
||||
this.showSuccess('登录成功')
|
||||
break
|
||||
case 'register':
|
||||
this.showSuccess('注册成功')
|
||||
break
|
||||
default:
|
||||
this.showSuccess('操作成功')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const errorHandler = HertzErrorHandler.getInstance()
|
||||
|
||||
// 导出便捷方法
|
||||
export const handleError = (error: any) => {
|
||||
if (error?.response) {
|
||||
errorHandler.handleHttpError(error)
|
||||
} else if (error?.code === 'NETWORK_ERROR' || error?.message?.includes('Network Error')) {
|
||||
errorHandler.handleNetworkError(error)
|
||||
} else {
|
||||
console.error('🚨 处理错误:', error)
|
||||
errorHandler.showError('操作失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
export const handleSuccess = (operation: string, customMessage?: string) => {
|
||||
errorHandler.handleSuccess(operation, customMessage)
|
||||
}
|
||||
154
hertz_server_django_ui/src/utils/hertz_permission.ts
Normal file
154
hertz_server_django_ui/src/utils/hertz_permission.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 权限管理工具类
|
||||
* 统一管理用户权限检查和菜单过滤逻辑
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/hertz_user'
|
||||
import { UserRole } from '@/router/admin_menu'
|
||||
|
||||
// 权限检查接口
|
||||
export interface PermissionChecker {
|
||||
hasRole(role: string): boolean
|
||||
hasPermission(permission: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAnyPermission(permissions: string[]): boolean
|
||||
isAdmin(): boolean
|
||||
isLoggedIn(): boolean
|
||||
}
|
||||
|
||||
// 权限管理类
|
||||
export class PermissionManager implements PermissionChecker {
|
||||
// 延迟获取 Pinia store,避免在 Pinia 未初始化时调用
|
||||
private get userStore() {
|
||||
return useUserStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有指定角色
|
||||
*/
|
||||
hasRole(role: string): boolean {
|
||||
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
|
||||
return userRoles.includes(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有指定权限
|
||||
*/
|
||||
hasPermission(permission: string): boolean {
|
||||
const userPermissions = this.userStore.userInfo?.permissions || []
|
||||
return userPermissions.includes(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有任意一个指定角色
|
||||
*/
|
||||
hasAnyRole(roles: string[]): boolean {
|
||||
const userRoles = this.userStore.userInfo?.roles?.map(r => r.role_code) || []
|
||||
return roles.some(role => userRoles.includes(role))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有任意一个指定权限
|
||||
*/
|
||||
hasAnyPermission(permissions: string[]): boolean {
|
||||
const userPermissions = this.userStore.userInfo?.permissions || []
|
||||
return permissions.some(permission => userPermissions.includes(permission))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否为管理员
|
||||
*/
|
||||
isAdmin(): boolean {
|
||||
const adminRoles = [UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.SUPER_ADMIN]
|
||||
return this.hasAnyRole(adminRoles)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
*/
|
||||
isLoggedIn(): boolean {
|
||||
return this.userStore.isLoggedIn && !!this.userStore.userInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色列表
|
||||
*/
|
||||
getUserRoles(): string[] {
|
||||
return this.userStore.userInfo?.roles?.map(r => r.role_code) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限列表
|
||||
*/
|
||||
getUserPermissions(): string[] {
|
||||
return this.userStore.userInfo?.permissions || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否可以访问指定路径
|
||||
*/
|
||||
canAccessPath(path: string, requiredRoles?: string[], requiredPermissions?: string[]): boolean {
|
||||
if (!this.isLoggedIn()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果没有指定权限要求,默认允许访问
|
||||
if (!requiredRoles && !requiredPermissions) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
if (!this.hasAnyRole(requiredRoles)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
if (!this.hasAnyPermission(requiredPermissions)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局权限管理实例
|
||||
export const permissionManager = new PermissionManager()
|
||||
|
||||
// 便捷的权限检查函数
|
||||
export const usePermission = () => {
|
||||
return {
|
||||
hasRole: (role: string) => permissionManager.hasRole(role),
|
||||
hasPermission: (permission: string) => permissionManager.hasPermission(permission),
|
||||
hasAnyRole: (roles: string[]) => permissionManager.hasAnyRole(roles),
|
||||
hasAnyPermission: (permissions: string[]) => permissionManager.hasAnyPermission(permissions),
|
||||
isAdmin: () => permissionManager.isAdmin(),
|
||||
isLoggedIn: () => permissionManager.isLoggedIn(),
|
||||
canAccessPath: (path: string, requiredRoles?: string[], requiredPermissions?: string[]) =>
|
||||
permissionManager.canAccessPath(path, requiredRoles, requiredPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
// Vue 3 组合式 API 权限检查 Hook
|
||||
export const usePermissionCheck = () => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
return {
|
||||
// 响应式权限检查
|
||||
hasRole: (role: string) => computed(() => permissionManager.hasRole(role)),
|
||||
hasPermission: (permission: string) => computed(() => permissionManager.hasPermission(permission)),
|
||||
hasAnyRole: (roles: string[]) => computed(() => permissionManager.hasAnyRole(roles)),
|
||||
hasAnyPermission: (permissions: string[]) => computed(() => permissionManager.hasAnyPermission(permissions)),
|
||||
isAdmin: computed(() => permissionManager.isAdmin()),
|
||||
isLoggedIn: computed(() => permissionManager.isLoggedIn()),
|
||||
|
||||
// 用户信息
|
||||
userRoles: computed(() => permissionManager.getUserRoles()),
|
||||
userPermissions: computed(() => permissionManager.getUserPermissions()),
|
||||
userInfo: computed(() => userStore.userInfo)
|
||||
}
|
||||
}
|
||||
201
hertz_server_django_ui/src/utils/hertz_request.ts
Normal file
201
hertz_server_django_ui/src/utils/hertz_request.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { handleError } from './hertz_error_handler'
|
||||
|
||||
// 请求配置接口
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
showLoading?: boolean
|
||||
showError?: boolean
|
||||
metadata?: {
|
||||
requestId: string
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
// 响应数据接口
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
// 请求拦截器配置
|
||||
const requestInterceptor = {
|
||||
onFulfilled: (config: RequestConfig) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const requestId = Math.random().toString(36).substr(2, 9)
|
||||
|
||||
// 简化日志,只在开发环境显示关键信息
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`)
|
||||
}
|
||||
|
||||
// 添加认证token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 如果是FormData,删除Content-Type让浏览器自动设置
|
||||
if (config.data instanceof FormData) {
|
||||
if (config.headers && 'Content-Type' in config.headers) {
|
||||
delete config.headers['Content-Type']
|
||||
}
|
||||
console.log('📦 检测到FormData,移除Content-Type让浏览器自动设置')
|
||||
}
|
||||
|
||||
// 显示loading
|
||||
if (config.showLoading !== false) {
|
||||
// 这里可以添加loading显示逻辑
|
||||
}
|
||||
|
||||
// 将requestId添加到config中,用于响应时匹配
|
||||
config.metadata = { requestId, timestamp }
|
||||
return config as InternalAxiosRequestConfig
|
||||
},
|
||||
onRejected: (error: any) => {
|
||||
console.error('❌ 请求错误:', error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 响应拦截器配置
|
||||
const responseInterceptor = {
|
||||
onFulfilled: (response: AxiosResponse) => {
|
||||
const requestTimestamp = (response.config as any).metadata?.timestamp
|
||||
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
|
||||
|
||||
// 简化日志,只在开发环境显示关键信息
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`)
|
||||
}
|
||||
|
||||
// 统一处理响应数据
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
// 如果后端返回的是标准格式 {code, message, data}
|
||||
if ('code' in response.data) {
|
||||
// 标准API响应格式处理
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
onRejected: (error: any) => {
|
||||
const requestTimestamp = (error.config as any)?.metadata?.timestamp
|
||||
const duration = requestTimestamp ? Date.now() - new Date(requestTimestamp).getTime() : 0
|
||||
|
||||
// 简化错误日志
|
||||
console.error(`❌ ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`)
|
||||
console.error('错误信息:', error.response?.data?.message || error.message)
|
||||
|
||||
// 使用统一错误处理器(支持按请求关闭全局错误提示)
|
||||
const showError = (error.config as any)?.showError
|
||||
if (showError !== false) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
// 特殊处理401错误
|
||||
if (error.response?.status === 401) {
|
||||
console.warn('🔒 未授权,清除token')
|
||||
localStorage.removeItem('token')
|
||||
// 可以在这里跳转到登录页
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
class HertzRequest {
|
||||
private instance: AxiosInstance
|
||||
|
||||
constructor(config: AxiosRequestConfig) {
|
||||
// 在开发环境中使用空字符串以便Vite代理正常工作
|
||||
// 在生产环境中使用完整的API地址
|
||||
const isDev = import.meta.env.DEV
|
||||
const baseURL = isDev ? '' : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000')
|
||||
console.log('🔧 创建axios实例 - isDev:', isDev)
|
||||
console.log('🔧 创建axios实例 - baseURL:', baseURL)
|
||||
console.log('🔧 环境变量 VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
this.instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
// 不设置默认Content-Type,让每个请求根据数据类型自动设置
|
||||
...config
|
||||
})
|
||||
|
||||
// 添加请求拦截器
|
||||
this.instance.interceptors.request.use(
|
||||
requestInterceptor.onFulfilled,
|
||||
requestInterceptor.onRejected
|
||||
)
|
||||
|
||||
// 添加响应拦截器
|
||||
this.instance.interceptors.response.use(
|
||||
responseInterceptor.onFulfilled,
|
||||
responseInterceptor.onRejected
|
||||
)
|
||||
}
|
||||
|
||||
// GET请求
|
||||
get<T = any>(url: string, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.get(url, config).then(res => res.data)
|
||||
}
|
||||
|
||||
// POST请求
|
||||
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
// 如果不是FormData,设置Content-Type为application/json
|
||||
const finalConfig = { ...config }
|
||||
if (!(data instanceof FormData)) {
|
||||
finalConfig.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...finalConfig.headers
|
||||
}
|
||||
}
|
||||
return this.instance.post(url, data, finalConfig).then(res => res.data)
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
// 如果不是FormData,设置Content-Type为application/json
|
||||
const finalConfig = { ...config }
|
||||
if (!(data instanceof FormData)) {
|
||||
finalConfig.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...finalConfig.headers
|
||||
}
|
||||
}
|
||||
return this.instance.put(url, data, finalConfig).then(res => res.data)
|
||||
}
|
||||
|
||||
// DELETE请求
|
||||
delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.delete(url, config).then(res => res.data)
|
||||
}
|
||||
|
||||
// PATCH请求
|
||||
patch<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.instance.patch(url, data, config).then(res => res.data)
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
upload<T = any>(url: string, formData: FormData, config?: RequestConfig): Promise<T> {
|
||||
// 不要手动设置Content-Type,让浏览器自动设置,这样会包含正确的boundary
|
||||
return this.instance.post(url, formData, {
|
||||
...config,
|
||||
headers: {
|
||||
// 不设置Content-Type,让浏览器自动设置multipart/form-data的header
|
||||
...config?.headers
|
||||
}
|
||||
}).then(res => res.data)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const request = new HertzRequest({})
|
||||
|
||||
// 导出类和配置接口
|
||||
export { HertzRequest }
|
||||
export type { RequestConfig, ApiResponse }
|
||||
138
hertz_server_django_ui/src/utils/hertz_router_utils.ts
Normal file
138
hertz_server_django_ui/src/utils/hertz_router_utils.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 路由工具函数
|
||||
* 用于动态路由相关的辅助功能
|
||||
*/
|
||||
|
||||
// 获取views目录下的所有Vue文件
|
||||
export const getViewFiles = () => {
|
||||
const viewsContext = import.meta.glob('@/views/*.vue')
|
||||
return Object.keys(viewsContext).map(path => path.split('/').pop())
|
||||
}
|
||||
|
||||
// 从文件名生成路由名称
|
||||
export const generateRouteName = (fileName: string): string => {
|
||||
return fileName.replace('.vue', '')
|
||||
}
|
||||
|
||||
// 从文件名生成路由路径
|
||||
export const generateRoutePath = (fileName: string): string => {
|
||||
const routeName = generateRouteName(fileName)
|
||||
let routePath = `/${routeName.toLowerCase()}`
|
||||
|
||||
// 处理特殊命名(驼峰转短横线)
|
||||
if (routeName !== routeName.toLowerCase()) {
|
||||
routePath = `/${routeName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`
|
||||
}
|
||||
|
||||
return routePath
|
||||
}
|
||||
|
||||
// 生成路由标题
|
||||
export const generateRouteTitle = (routeName: string): string => {
|
||||
const titleMap: Record<string, string> = {
|
||||
Dashboard: '仪表板',
|
||||
User: '用户管理',
|
||||
Profile: '个人资料',
|
||||
Settings: '系统设置',
|
||||
Test: '样式测试',
|
||||
WebSocketTest: 'WebSocket测试',
|
||||
NotFound: '页面未找到',
|
||||
}
|
||||
|
||||
return titleMap[routeName] || routeName
|
||||
}
|
||||
|
||||
// 判断路由是否需要认证
|
||||
export const shouldRequireAuth = (routeName: string): boolean => {
|
||||
const publicRoutes = ['Test', 'WebSocketTest']
|
||||
return !(
|
||||
publicRoutes.includes(routeName) || // 公开路由列表
|
||||
routeName.startsWith('Demo') // Demo开头的页面不需要认证
|
||||
)
|
||||
}
|
||||
|
||||
// 获取公开路由列表
|
||||
export const getPublicRoutes = (): string[] => {
|
||||
return ['Test', 'WebSocketTest', 'Demo'] // 可以添加更多公开路由
|
||||
}
|
||||
|
||||
// 打印路由调试信息
|
||||
export const debugRoutes = () => {
|
||||
const viewFiles = getViewFiles()
|
||||
const fixedFiles = ['Home.vue', 'Login.vue']
|
||||
const dynamicFiles = viewFiles.filter(file => !fixedFiles.includes(file) && file !== 'NotFound.vue')
|
||||
|
||||
console.log('🔍 路由调试信息:')
|
||||
console.log('📁 所有视图文件:', viewFiles)
|
||||
console.log('🔒 固定路由文件:', fixedFiles)
|
||||
console.log('🚀 动态路由文件:', dynamicFiles)
|
||||
|
||||
const publicRoutes = getPublicRoutes()
|
||||
console.log('🔓 公开路由 (不需要认证):', publicRoutes)
|
||||
|
||||
console.log('\n📋 动态路由配置:')
|
||||
dynamicFiles.forEach(file => {
|
||||
const routeName = generateRouteName(file)
|
||||
const routePath = generateRoutePath(file)
|
||||
const title = generateRouteTitle(routeName)
|
||||
const requiresAuth = shouldRequireAuth(routeName)
|
||||
const isPublic = !requiresAuth
|
||||
|
||||
console.log(` ${file} → ${routePath} (${title}) ${isPublic ? '🔓' : '🔒'}`)
|
||||
})
|
||||
|
||||
console.log('\n🎯 Demo页面特殊说明:')
|
||||
console.log(' - Demo开头的页面不需要认证 (Demo.vue, DemoPage.vue等)')
|
||||
console.log(' - 可以直接访问 /demo 路径')
|
||||
}
|
||||
|
||||
// 在开发环境中自动调用调试函数
|
||||
if (import.meta.env.DEV) {
|
||||
debugRoutes()
|
||||
}
|
||||
|
||||
// 提供全局访问的路由信息查看函数
|
||||
export const showRoutesInfo = () => {
|
||||
console.log('🚀 Hertz Admin 路由配置信息:')
|
||||
console.log('📋 完整路由列表:')
|
||||
|
||||
// 注意: 这里需要从路由实例中获取真实数据
|
||||
// 由于路由工具函数在路由配置之前加载,这里提供的是示例数据
|
||||
// 实际的动态路由信息会在项目启动时通过logRouteInfo()函数显示
|
||||
|
||||
console.log('\n🔒 固定路由 (需要手动配置):')
|
||||
console.log(' 🔒 / → Home (首页)')
|
||||
console.log(' 🔓 /login → Login (登录)')
|
||||
|
||||
console.log('\n🚀 动态路由 (自动生成):')
|
||||
console.log(' 🔒 /dashboard → Dashboard (仪表板)')
|
||||
console.log(' 🔒 /user → User (用户管理)')
|
||||
console.log(' 🔒 /profile → Profile (个人资料)')
|
||||
console.log(' 🔒 /settings → Settings (系统设置)')
|
||||
console.log(' 🔓 /test → Test (样式测试)')
|
||||
console.log(' 🔓 /websocket-test → WebSocketTest (WebSocket测试)')
|
||||
console.log(' 🔓 /demo → Demo (动态路由演示)')
|
||||
|
||||
console.log('\n❓ 404路由:')
|
||||
console.log(' ❓ /:pathMatch(.*)* → NotFound (页面未找到)')
|
||||
|
||||
console.log('\n📖 访问说明:')
|
||||
console.log(' 🔓 公开路由: 可以直接访问,不需要登录')
|
||||
console.log(' 🔒 私有路由: 需要登录后才能访问')
|
||||
console.log(' 💡 提示: 可以在浏览器中直接访问这些路径')
|
||||
|
||||
console.log('\n🌐 可用链接:')
|
||||
console.log(' http://localhost:3000/ - 首页 (需要登录)')
|
||||
console.log(' http://localhost:3000/login - 登录页面')
|
||||
console.log(' http://localhost:3000/dashboard - 仪表板 (需要登录)')
|
||||
console.log(' http://localhost:3000/user - 用户管理 (需要登录)')
|
||||
console.log(' http://localhost:3000/profile - 个人资料 (需要登录)')
|
||||
console.log(' http://localhost:3000/settings - 系统设置 (需要登录)')
|
||||
console.log(' http://localhost:3000/test - 样式测试 (公开)')
|
||||
console.log(' http://localhost:3000/websocket-test - WebSocket测试 (公开)')
|
||||
console.log(' http://localhost:3000/demo - 动态路由演示 (公开)')
|
||||
console.log(' http://localhost:3000/any-other-path - 404页面 (公开)')
|
||||
|
||||
console.log('\n✅ 路由配置加载完成!')
|
||||
console.log('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
|
||||
}
|
||||
128
hertz_server_django_ui/src/utils/hertz_url.ts
Normal file
128
hertz_server_django_ui/src/utils/hertz_url.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* URL处理工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取完整的文件URL
|
||||
* @param relativePath 相对路径,如 /media/detection/original/xxx.jpg
|
||||
* @returns 完整的URL
|
||||
*/
|
||||
export function getFullFileUrl(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
console.warn('⚠️ 文件路径为空')
|
||||
return ''
|
||||
}
|
||||
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// 在开发环境中,使用相对路径(通过Vite代理)
|
||||
if (import.meta.env.DEV) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// 在生产环境中,拼接完整的URL
|
||||
const baseURL = getBackendBaseUrl()
|
||||
return `${baseURL}${relativePath}`
|
||||
}
|
||||
|
||||
export function getBackendBaseUrl(): string {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
export function getWsBaseUrl(): string {
|
||||
const httpBase = getBackendBaseUrl()
|
||||
if (httpBase.startsWith('https://')) {
|
||||
return 'wss://' + httpBase.slice('https://'.length)
|
||||
}
|
||||
if (httpBase.startsWith('http://')) {
|
||||
return 'ws://' + httpBase.slice('http://'.length)
|
||||
}
|
||||
return httpBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API基础URL
|
||||
* @returns API基础URL
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (import.meta.env.DEV) {
|
||||
return '' // 开发环境使用空字符串,通过Vite代理
|
||||
}
|
||||
return getBackendBaseUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体文件基础URL
|
||||
* @returns 媒体文件基础URL
|
||||
*/
|
||||
export function getMediaBaseUrl(): string {
|
||||
if (import.meta.env.DEV) {
|
||||
return '' // 开发环境使用空字符串,通过Vite代理
|
||||
}
|
||||
const baseURL = getBackendBaseUrl()
|
||||
return baseURL.replace('/api', '') // 移除/api后缀
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查URL是否可访问
|
||||
* @param url 要检查的URL
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
export async function checkUrlAccessibility(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('❌ URL访问检查失败:', url, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param filename 文件名
|
||||
* @returns 文件扩展名
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为图片文件
|
||||
* @param filename 文件名或URL
|
||||
* @returns 是否为图片文件
|
||||
*/
|
||||
export function isImageFile(filename: string): boolean {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||
const extension = getFileExtension(filename)
|
||||
return imageExtensions.includes(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为视频文件
|
||||
* @param filename 文件名或URL
|
||||
* @returns 是否为视频文件
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
const videoExtensions = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv']
|
||||
const extension = getFileExtension(filename)
|
||||
return videoExtensions.includes(extension)
|
||||
}
|
||||
251
hertz_server_django_ui/src/utils/hertz_utils.ts
Normal file
251
hertz_server_django_ui/src/utils/hertz_utils.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useAppStore } from '@/stores/hertz_app'
|
||||
|
||||
// 日期格式化
|
||||
export const formatDate = (date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss') => {
|
||||
const d = new Date(date)
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', year.toString())
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastCall = 0
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastCall >= delay) {
|
||||
lastCall = now
|
||||
func(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深拷贝
|
||||
export const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime()) as T
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item)) as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const cloned = {} as T
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
cloned[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// 数组去重
|
||||
export const unique = <T>(arr: T[]): T[] => {
|
||||
return Array.from(new Set(arr))
|
||||
}
|
||||
|
||||
// 获取URL参数
|
||||
export const getUrlParam = (name: string, url?: string): string | null => {
|
||||
const searchUrl = url || window.location.search
|
||||
const params = new URLSearchParams(searchUrl)
|
||||
return params.get(name)
|
||||
}
|
||||
|
||||
// 设置URL参数
|
||||
export const setUrlParam = (name: string, value: string, url?: string): string => {
|
||||
const searchUrl = url || window.location.search
|
||||
const params = new URLSearchParams(searchUrl)
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
params.delete(name)
|
||||
} else {
|
||||
params.set(name, value)
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// 降级处理
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const successful = document.execCommand('copy')
|
||||
textArea.remove()
|
||||
|
||||
if (!successful) {
|
||||
throw new Error('复制失败')
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
export const downloadFile = (url: string, filename?: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename || ''
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// 验证手机号格式(中国大陆)
|
||||
export const isValidPhone = (phone: string): boolean => {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
// 验证身份证号
|
||||
export const isValidIdCard = (idCard: string): boolean => {
|
||||
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
|
||||
return idCardRegex.test(idCard)
|
||||
}
|
||||
|
||||
// 生成随机字符串
|
||||
export const generateRandomString = (length: number = 8): string => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 等待函数
|
||||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 获取浏览器信息
|
||||
export const getBrowserInfo = () => {
|
||||
const userAgent = navigator.userAgent
|
||||
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)
|
||||
const isFirefox = /Firefox/.test(userAgent)
|
||||
const isSafari = /Safari/.test(userAgent) && /Apple Computer/.test(navigator.vendor)
|
||||
const isEdge = /Edg/.test(userAgent)
|
||||
const isIE = /MSIE|Trident/.test(userAgent)
|
||||
|
||||
return {
|
||||
isChrome,
|
||||
isFirefox,
|
||||
isSafari,
|
||||
isEdge,
|
||||
isIE,
|
||||
userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
// 本地存储封装
|
||||
export const storage = {
|
||||
get: <T>(key: string, defaultValue?: T): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : (defaultValue ?? null)
|
||||
} catch (error) {
|
||||
console.error(`获取本地存储失败 (${key}):`, error)
|
||||
return defaultValue ?? null
|
||||
}
|
||||
},
|
||||
|
||||
set: <T>(key: string, value: T): void => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch (error) {
|
||||
console.error(`设置本地存储失败 (${key}):`, error)
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch (error) {
|
||||
console.error(`删除本地存储失败 (${key}):`, error)
|
||||
}
|
||||
},
|
||||
|
||||
clear: (): void => {
|
||||
try {
|
||||
localStorage.clear()
|
||||
} catch (error) {
|
||||
console.error('清空本地存储失败:', error)
|
||||
}
|
||||
},
|
||||
}
|
||||
112
hertz_server_django_ui/src/utils/menu_mapping.ts
Normal file
112
hertz_server_django_ui/src/utils/menu_mapping.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { menuApi, type Menu } from '@/api/menu'
|
||||
|
||||
// 菜单key和菜单ID的映射关系
|
||||
let menuKeyToIdMap: Map<string, number> = new Map()
|
||||
let menuIdToKeyMap: Map<number, string> = new Map()
|
||||
let isInitialized = false
|
||||
|
||||
// 菜单key和菜单code的映射关系(用于建立映射)
|
||||
const MENU_KEY_TO_CODE_MAP: { [key: string]: string } = {
|
||||
'dashboard': 'dashboard',
|
||||
'user-management': 'user_management',
|
||||
'department-management': 'department_management',
|
||||
'menu-management': 'menu_management',
|
||||
'teacher': 'role_management'
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化菜单映射
|
||||
*/
|
||||
export const initializeMenuMapping = async (): Promise<void> => {
|
||||
try {
|
||||
// 获取菜单树数据
|
||||
const response = await menuApi.getMenuTree()
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 清空现有映射
|
||||
menuKeyToIdMap.clear()
|
||||
|
||||
// 递归处理菜单树
|
||||
const processMenuTree = (menus: Menu[]) => {
|
||||
menus.forEach(menu => {
|
||||
if (menu.key && menu.id) {
|
||||
menuKeyToIdMap.set(menu.key, menu.id)
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
processMenuTree(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
processMenuTree(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化菜单映射时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归构建菜单映射关系
|
||||
*/
|
||||
const buildMenuMapping = (menus: Menu[]): void => {
|
||||
menus.forEach(menu => {
|
||||
// 根据menu_code找到对应的key
|
||||
const menuKey = Object.keys(MENU_KEY_TO_CODE_MAP).find(
|
||||
key => MENU_KEY_TO_CODE_MAP[key] === menu.menu_code
|
||||
)
|
||||
|
||||
if (menuKey) {
|
||||
menuKeyToIdMap.set(menuKey, menu.menu_id)
|
||||
menuIdToKeyMap.set(menu.menu_id, menuKey)
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
buildMenuMapping(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据菜单key获取菜单ID
|
||||
*/
|
||||
export const getMenuIdByKey = (menuKey: string): number | undefined => {
|
||||
return menuKeyToIdMap.get(menuKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据菜单ID获取菜单key
|
||||
*/
|
||||
export const getMenuKeyById = (menuId: number): string | undefined => {
|
||||
return menuIdToKeyMap.get(menuId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有指定菜单的权限
|
||||
*/
|
||||
export const hasMenuPermissionById = (menuKey: string, userMenuPermissions: number[]): boolean => {
|
||||
const menuId = getMenuIdByKey(menuKey)
|
||||
|
||||
if (!menuId) {
|
||||
// 降级策略:如果没有找到菜单映射,则允许显示(向后兼容)
|
||||
return true
|
||||
}
|
||||
|
||||
return userMenuPermissions.includes(menuId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户有权限的菜单keys
|
||||
*/
|
||||
export const getPermittedMenuKeys = (userMenuPermissions: number[]): string[] => {
|
||||
const permittedKeys: string[] = []
|
||||
userMenuPermissions.forEach(menuId => {
|
||||
const menuKey = getMenuKeyById(menuId)
|
||||
if (menuKey) {
|
||||
permittedKeys.push(menuKey)
|
||||
}
|
||||
})
|
||||
return permittedKeys
|
||||
}
|
||||
730
hertz_server_django_ui/src/utils/yolo_frontend.ts
Normal file
730
hertz_server_django_ui/src/utils/yolo_frontend.ts
Normal file
@@ -0,0 +1,730 @@
|
||||
// 前端ONNX YOLO检测工具类
|
||||
import * as ort from 'onnxruntime-web'
|
||||
|
||||
// ONNX检测结果接口
|
||||
export interface YOLODetectionResult {
|
||||
detections: Array<{
|
||||
class_name: string
|
||||
confidence: number
|
||||
bbox: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}>
|
||||
object_count: number
|
||||
detected_categories: string[]
|
||||
confidence_scores: number[]
|
||||
avg_confidence: number
|
||||
annotated_image: string // base64图像
|
||||
processing_time: number
|
||||
}
|
||||
|
||||
// 不预置任何类别名称;等待后端或标签文件提供
|
||||
|
||||
class YOLODetector {
|
||||
private session: ort.InferenceSession | null = null
|
||||
private modelPath: string = ''
|
||||
private classNames: string[] = []
|
||||
private inputShape: [number, number] = [640, 640] // 默认输入尺寸(可在 WASM 下动态调小)
|
||||
private currentEP: 'webgpu' | 'webgl' | 'wasm' = 'wasm'
|
||||
|
||||
/**
|
||||
* 加载ONNX模型
|
||||
* @param modelPath 模型路径(相对于public目录)
|
||||
* @param classNames 类别名称列表(可选,如果不提供则使用默认COCO类别)
|
||||
*/
|
||||
async loadModel(modelPath: string, classNames?: string[], forceEP?: 'webgpu' | 'webgl' | 'wasm'): Promise<void> {
|
||||
try {
|
||||
console.log('🔄 开始加载ONNX模型:', modelPath)
|
||||
|
||||
// 设置类别名称
|
||||
if (classNames && classNames.length > 0) {
|
||||
this.classNames = classNames
|
||||
console.log('📦 使用自定义类别:', classNames.length, '个类别')
|
||||
} else {
|
||||
// 如果未提供类别,稍后根据输出维度自动推断数量并用 class_0.. 命名
|
||||
this.classNames = []
|
||||
console.log('📦 未提供类别,将根据模型输出自动推断类别数量')
|
||||
}
|
||||
|
||||
// 动态选择可用的 wasm 资源路径,避免 404/HTML 导致的“magic word”错误
|
||||
const ensureWasmPath = async () => {
|
||||
const candidates = [
|
||||
'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/',
|
||||
'https://unpkg.com/onnxruntime-web@1.23.2/dist/',
|
||||
'/onnxruntime-web/', // 如果你把 dist 拷贝到 public/onnxruntime-web/
|
||||
'/ort/' // 或者 public/ort/
|
||||
]
|
||||
for (const base of candidates) {
|
||||
try {
|
||||
const testUrl = base.replace(/\/$/, '') + '/ort-wasm.wasm'
|
||||
const res = await fetch(testUrl, { method: 'HEAD', cache: 'no-store', mode: 'no-cors' as any })
|
||||
// no-cors 模式下 status 为 0,也视为可用(跨域但可下载)
|
||||
if (res.ok || res.status === 0) {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.wasmPaths = base
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
await ensureWasmPath()
|
||||
|
||||
// 配置 WASM 线程:若不支持跨域隔离/SharedArrayBuffer,则退回单线程,避免“worker not ready”
|
||||
const canMultiThread = (self as any).crossOriginIsolated && typeof (self as any).SharedArrayBuffer !== 'undefined'
|
||||
try {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = canMultiThread ? Math.max(2, Math.min(4, (navigator as any)?.hardwareConcurrency || 2)) : 1
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
} catch {}
|
||||
|
||||
const createWithEP = async (ep: 'webgpu' | 'webgl' | 'wasm') => {
|
||||
if (ep === 'webgpu') {
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['webgpu'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'webgpu'
|
||||
return
|
||||
}
|
||||
if (ep === 'webgl') {
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['webgl'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'webgl'
|
||||
return
|
||||
}
|
||||
// wasm
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['wasm'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'wasm'
|
||||
}
|
||||
|
||||
// 配置 ONNX Runtime:优先 GPU(WebGPU/WebGL),再回退 WASM
|
||||
// 支持通过 localStorage 开关强制使用 WASM:localStorage.setItem('ort_force_wasm','1')
|
||||
// 也可通过第三个参数 forceEP 指定(用于错误时的程序化降级)
|
||||
const forceWasm = forceEP === 'wasm' || (localStorage.getItem('ort_force_wasm') === '1')
|
||||
// 1) WebGPU(实验性,浏览器需支持 navigator.gpu)
|
||||
let created = false
|
||||
if (!forceWasm && (navigator as any)?.gpu && (!forceEP || forceEP === 'webgpu')) {
|
||||
try {
|
||||
// 动态引入 webgpu 版本(若不支持不会打包)
|
||||
await import('onnxruntime-web/webgpu')
|
||||
await createWithEP('webgpu')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGPU 推理')
|
||||
} catch (e) {
|
||||
console.warn('⚠️ WebGPU 初始化失败,回退到 WebGL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) WebGL(GPU 加速,兼容更好)
|
||||
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
|
||||
try {
|
||||
await createWithEP('webgl')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGL 推理')
|
||||
} catch (e2) {
|
||||
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) WASM(CPU)
|
||||
if (!created) {
|
||||
try {
|
||||
// 设置 WASM 线程/特性(路径已在 ensureWasmPath 中选择)
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = Math.max(1, Math.min(4, (navigator as any)?.hardwareConcurrency || 2))
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
} catch {}
|
||||
|
||||
await createWithEP('wasm')
|
||||
console.log('✅ 使用 WASM 推理')
|
||||
}
|
||||
this.modelPath = modelPath
|
||||
|
||||
// 根据后端动态调整输入尺寸:WASM 默认调小以提升流畅度,可用 localStorage 覆盖
|
||||
try {
|
||||
const override = parseInt(localStorage.getItem('ort_input_size') || '', 10)
|
||||
if (Number.isFinite(override) && override >= 256 && override <= 1024) {
|
||||
this.inputShape = [override, override] as any
|
||||
} else if (this.currentEP === 'wasm') {
|
||||
this.inputShape = [512, 512] as any
|
||||
} else {
|
||||
this.inputShape = [640, 640] as any
|
||||
}
|
||||
console.log('🧩 推理输入尺寸:', this.inputShape[0])
|
||||
} catch {}
|
||||
|
||||
// 获取模型输入输出信息(兼容性更强的写法)
|
||||
const inputNames = this.session.inputNames
|
||||
const outputNames = this.session.outputNames
|
||||
console.log('✅ 模型加载成功')
|
||||
console.log('📥 输入:', inputNames)
|
||||
console.log('📤 输出:', outputNames)
|
||||
|
||||
// 尝试从 outputMetadata 推断类别数(某些环境不提供 dims,需要兜底)
|
||||
try {
|
||||
if (outputNames && outputNames.length > 0) {
|
||||
const outputMetadata: any = (this.session as any).outputMetadata
|
||||
const outputName = outputNames[0]
|
||||
const meta = outputMetadata?.[outputName]
|
||||
const outputShape: number[] | undefined = meta?.dims
|
||||
if (Array.isArray(outputShape) && outputShape.length >= 3) {
|
||||
const numClasses = (outputShape[2] as number) - 5 // YOLO: [N, B, 5+C]
|
||||
if (Number.isFinite(numClasses) && numClasses > 0 && numClasses !== this.classNames.length) {
|
||||
console.warn(`⚠️ 模型输出类别数 (${numClasses}) 与提供的类别数 (${this.classNames.length}) 不匹配/或未提供`)
|
||||
if (this.classNames.length === 0) {
|
||||
this.classNames = Array.from({ length: numClasses }, (_, i) => `class_${i}`)
|
||||
console.log('📦 根据模型输出调整类别数量为:', numClasses)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 无法从 outputMetadata 推断输出维度,将在首次推理时根据输出tensor推断。')
|
||||
}
|
||||
}
|
||||
} catch (metaErr) {
|
||||
console.warn('⚠️ 读取 outputMetadata 失败,将在首次推理时推断类别数。', metaErr)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 加载模型失败:', error)
|
||||
throw new Error(`加载ONNX模型失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
return this.session !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的模型路径
|
||||
*/
|
||||
getModelPath(): string {
|
||||
return this.modelPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类别名称列表
|
||||
*/
|
||||
getClassNames(): string[] {
|
||||
return this.classNames
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理图像(Ultralytics letterbox:保比例缩放+灰边填充)
|
||||
* 返回输入张量与还原坐标所需的比例与padding
|
||||
*/
|
||||
private preprocessImage(image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement): {
|
||||
input: Float32Array
|
||||
ratio: number
|
||||
padX: number
|
||||
padY: number
|
||||
dstW: number
|
||||
dstH: number
|
||||
srcW: number
|
||||
srcH: number
|
||||
} {
|
||||
const dstW = this.inputShape[0]
|
||||
const dstH = this.inputShape[1]
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('无法创建canvas上下文')
|
||||
|
||||
const srcW = image instanceof HTMLVideoElement ? image.videoWidth : (image as HTMLImageElement | HTMLCanvasElement).width
|
||||
const srcH = image instanceof HTMLVideoElement ? image.videoHeight : (image as HTMLImageElement | HTMLCanvasElement).height
|
||||
|
||||
// 计算 letterbox
|
||||
const r = Math.min(dstW / srcW, dstH / srcH)
|
||||
const newW = Math.round(srcW * r)
|
||||
const newH = Math.round(srcH * r)
|
||||
const padX = Math.floor((dstW - newW) / 2)
|
||||
const padY = Math.floor((dstH - newH) / 2)
|
||||
|
||||
canvas.width = dstW
|
||||
canvas.height = dstH
|
||||
// 背景填充灰色(114)与 Ultralytics 一致
|
||||
ctx.fillStyle = 'rgb(114,114,114)'
|
||||
ctx.fillRect(0, 0, dstW, dstH)
|
||||
// 绘制等比缩放后的图像到中间
|
||||
ctx.drawImage(image as any, 0, 0, srcW, srcH, padX, padY, newW, newH)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, dstW, dstH)
|
||||
const data = imageData.data
|
||||
|
||||
const input = new Float32Array(3 * dstW * dstH)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r8 = data[i] / 255.0
|
||||
const g8 = data[i + 1] / 255.0
|
||||
const b8 = data[i + 2] / 255.0
|
||||
const idx = i / 4
|
||||
input[idx] = r8
|
||||
input[idx + dstW * dstH] = g8
|
||||
input[idx + dstW * dstH * 2] = b8
|
||||
}
|
||||
|
||||
return { input, ratio: r, padX, padY, dstW, dstH, srcW, srcH }
|
||||
}
|
||||
|
||||
/**
|
||||
* 非极大值抑制(NMS)
|
||||
*/
|
||||
private nms(boxes: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}>, iouThreshold: number): number[] {
|
||||
if (boxes.length === 0) return []
|
||||
|
||||
// 按置信度排序
|
||||
boxes.sort((a, b) => b.conf - a.conf)
|
||||
|
||||
const selected: number[] = []
|
||||
const suppressed = new Set<number>()
|
||||
|
||||
for (let i = 0; i <boxes.length; i++) {
|
||||
if (suppressed.has(i)) continue
|
||||
|
||||
selected.push(i)
|
||||
const box1 = boxes[i]
|
||||
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
if (suppressed.has(j)) continue
|
||||
|
||||
const box2 = boxes[j]
|
||||
|
||||
// 计算IoU
|
||||
const iou = this.calculateIoU(box1, box2)
|
||||
|
||||
if (iou > iouThreshold) {
|
||||
suppressed.add(j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算IoU(交并比)
|
||||
*/
|
||||
private calculateIoU(box1: {x: number, y: number, w: number, h: number}, box2: {x: number, y: number, w: number, h: number}): number {
|
||||
const x1 = Math.max(box1.x, box2.x)
|
||||
const y1 = Math.max(box1.y, box2.y)
|
||||
const x2 = Math.min(box1.x + box1.w, box2.x + box2.w)
|
||||
const y2 = Math.min(box1.y + box1.h, box2.y + box2.h)
|
||||
|
||||
if (x2 < x1 || y2 < y1) return 0
|
||||
|
||||
const intersection = (x2 - x1) * (y2 - y1)
|
||||
const area1 = box1.w * box1.h
|
||||
const area2 = box2.w * box2.h
|
||||
const union = area1 + area2 - intersection
|
||||
|
||||
return intersection / union
|
||||
}
|
||||
|
||||
/**
|
||||
* 后处理检测结果
|
||||
*/
|
||||
private postprocess(
|
||||
output: ort.Tensor,
|
||||
meta: { ratio: number; padX: number; padY: number; srcW: number; srcH: number },
|
||||
confThreshold: number,
|
||||
nmsThreshold: number,
|
||||
opts?: { maxDetections?: number; minBoxArea?: number; classWise?: boolean }
|
||||
): Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}> {
|
||||
const outputData = output.data as Float32Array
|
||||
const outputShape = output.dims || []
|
||||
|
||||
// YOLO输出常见两种:
|
||||
// A) [1, num_boxes, 5+num_classes]
|
||||
// B) [1, 5+num_classes, num_boxes]
|
||||
// 另外也可能已经扁平化为 [num_boxes, 5+num_classes]
|
||||
let numBoxes = 0
|
||||
let numFeatures = 0
|
||||
if (outputShape.length === 3) {
|
||||
// 取更大的作为 boxes 维度(通常是 8400),较小的是 5+C(通常是 85)
|
||||
const a = outputShape[1] as number
|
||||
const b = outputShape[2] as number
|
||||
if (a >= b) {
|
||||
numBoxes = a
|
||||
numFeatures = b
|
||||
} else {
|
||||
numBoxes = b
|
||||
numFeatures = a
|
||||
}
|
||||
} else if (outputShape.length === 2) {
|
||||
numBoxes = outputShape[0] as number
|
||||
numFeatures = outputShape[1] as number
|
||||
} else {
|
||||
// 无维度信息时根据长度推断(保底)
|
||||
numFeatures = 85
|
||||
numBoxes = Math.floor(outputData.length / numFeatures)
|
||||
}
|
||||
const numClasses = Math.max(0, numFeatures - 5)
|
||||
|
||||
const detections: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
|
||||
|
||||
// 还原到原图坐标:先减去 padding,再除以 ratio
|
||||
const { ratio, padX, padY, srcW: originalWidth, srcH: originalHeight } = meta
|
||||
|
||||
// 获取 (row i, col j) 的值,兼容布局 A/B
|
||||
const getVal = (i: number, j: number): number => {
|
||||
if (outputShape.length === 3) {
|
||||
const a = outputShape[1] as number
|
||||
const b = outputShape[2] as number
|
||||
if (a >= b) {
|
||||
// [1, boxes, features]
|
||||
return outputData[i * b + j]
|
||||
}
|
||||
// [1, features, boxes]
|
||||
return outputData[j * a + i]
|
||||
}
|
||||
// [boxes, features]
|
||||
return outputData[i * numFeatures + j]
|
||||
}
|
||||
|
||||
// sigmoid
|
||||
const sigmoid = (v: number) => 1 / (1 + Math.exp(-v))
|
||||
|
||||
// 情况一:部分导出的ONNX已经做过NMS,输出形如 [num, 6]:x1,y1,x2,y2,score,classId(或其它顺序)。
|
||||
const tryPostNmsLayouts = () => {
|
||||
const candidates: Array<(row: (j:number)=>number) => {x:number,y:number,w:number,h:number,conf:number,cls:number} | null> = [
|
||||
// [x1,y1,x2,y2,score,cls]
|
||||
(get) => {
|
||||
const x1 = get(0), y1 = get(1), x2 = get(2), y2 = get(3)
|
||||
const score = get(4), cls = get(5)
|
||||
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
|
||||
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
},
|
||||
// [cls,score,x1,y1,x2,y2]
|
||||
(get) => {
|
||||
const cls = get(0), score = get(1), x1 = get(2), y1 = get(3), x2 = get(4), y2 = get(5)
|
||||
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
|
||||
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
},
|
||||
// [x,y,w,h,score,cls](xywh)
|
||||
(get) => {
|
||||
const x = get(0), y = get(1), w = get(2), h = get(3), score = get(4), cls = get(5)
|
||||
if (!isFinite(x+y+w+h+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
return { x: x - w/2, y: y - h/2, w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
}
|
||||
]
|
||||
const out: typeof detections = []
|
||||
for (let i = 0; i < numBoxes; i++) {
|
||||
const getter = (j:number) => getVal(i, j)
|
||||
let picked = null
|
||||
for (const decode of candidates) {
|
||||
picked = decode(getter)
|
||||
if (picked && picked.conf >= confThreshold) break
|
||||
}
|
||||
if (!picked || picked.conf < confThreshold) continue
|
||||
// 还原坐标
|
||||
let { x, y, w, h, conf, cls } = picked
|
||||
x = (x - padX) / ratio
|
||||
y = (y - padY) / ratio
|
||||
w = w / ratio
|
||||
h = h / ratio
|
||||
const area = Math.max(0, w) * Math.max(0, h)
|
||||
const minArea = opts?.minBoxArea ?? (meta.srcW * meta.srcH * 0.0001)
|
||||
if (area <= 0 || area < minArea) continue
|
||||
out.push({ x, y, w, h, conf, class: cls })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 情况二:原始预测 [*, *, 5+num_classes],需要 obj × class 计算。
|
||||
// 支持两种坐标格式:xywh(中心点) 与 xyxy(左上/右下)。优先取能得到更多有效框的解码。
|
||||
const decode = (mode: 'xywh' | 'xyxy') => {
|
||||
const out: typeof detections = []
|
||||
for (let i = 0; i < numBoxes; i++) {
|
||||
const v0 = getVal(i, 0)
|
||||
const v1 = getVal(i, 1)
|
||||
const v2 = getVal(i, 2)
|
||||
const v3 = getVal(i, 3)
|
||||
const objConf = sigmoid(getVal(i, 4))
|
||||
|
||||
// 最大类别
|
||||
let maxClassConf = 0
|
||||
let maxClassIdx = 0
|
||||
for (let j = 0; j < numClasses; j++) {
|
||||
const classConf = sigmoid(getVal(i, 5 + j))
|
||||
if (classConf > maxClassConf) {
|
||||
maxClassConf = classConf
|
||||
maxClassIdx = j
|
||||
}
|
||||
}
|
||||
const confidence = objConf * maxClassConf
|
||||
if (confidence < confThreshold) continue
|
||||
|
||||
let x = 0, y = 0, w = 0, h = 0
|
||||
if (mode === 'xywh') {
|
||||
const xc = (v0 - padX) / ratio
|
||||
const yc = (v1 - padY) / ratio
|
||||
const wv = v2 / ratio
|
||||
const hv = v3 / ratio
|
||||
x = xc - wv / 2
|
||||
y = yc - hv / 2
|
||||
w = wv
|
||||
h = hv
|
||||
} else {
|
||||
// xyxy
|
||||
const x1 = (v0 - padX) / ratio
|
||||
const y1 = (v1 - padY) / ratio
|
||||
const x2 = (v2 - padX) / ratio
|
||||
const y2 = (v3 - padY) / ratio
|
||||
x = Math.min(x1, x2)
|
||||
y = Math.min(y1, y2)
|
||||
w = Math.abs(x2 - x1)
|
||||
h = Math.abs(y2 - y1)
|
||||
}
|
||||
const area = Math.max(0, w) * Math.max(0, h)
|
||||
const minArea = opts?.minBoxArea ?? (originalWidth * originalHeight * 0.00005) // 放宽:0.005%
|
||||
if (area <= 0 || area < minArea) continue
|
||||
if (w > 4 * originalWidth || h > 4 * originalHeight) continue // 明显异常
|
||||
|
||||
out.push({ x, y, w, h, conf: confidence, class: maxClassIdx })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let pick: typeof detections = []
|
||||
// 若特征维很小(<=6),优先按“已NMS格式”解析
|
||||
if (numFeatures <= 6) {
|
||||
pick = tryPostNmsLayouts()
|
||||
}
|
||||
// 否则按原始格式解码
|
||||
if (pick.length === 0) {
|
||||
const d1 = decode('xywh')
|
||||
const d2 = decode('xyxy')
|
||||
pick = d2.length > d1.length ? d2 : d1
|
||||
}
|
||||
detections.push(...pick)
|
||||
// 执行NMS(支持按类别)
|
||||
const classWise = opts?.classWise ?? true
|
||||
let kept: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
|
||||
if (classWise) {
|
||||
const byClass: Record<number, typeof detections> = {}
|
||||
for (const d of detections) {
|
||||
(byClass[d.class] ||= []).push(d)
|
||||
}
|
||||
for (const k in byClass) {
|
||||
const group = byClass[k]
|
||||
const idxs = this.nms(group, nmsThreshold)
|
||||
kept.push(...idxs.map(i => group[i]))
|
||||
}
|
||||
} else {
|
||||
const idxs = this.nms(detections, nmsThreshold)
|
||||
kept = idxs.map(i => detections[i])
|
||||
}
|
||||
|
||||
// 置信度排序并限制最大数量
|
||||
kept.sort((a, b) => b.conf - a.conf)
|
||||
const limited = kept.slice(0, opts?.maxDetections ?? 100)
|
||||
|
||||
// 构建最终结果
|
||||
return limited.map(det => {
|
||||
const className = this.classNames[det.class] || `class_${det.class}`
|
||||
return {
|
||||
class_name: className,
|
||||
confidence: det.conf,
|
||||
bbox: {
|
||||
x: Math.max(0, det.x),
|
||||
y: Math.max(0, det.y),
|
||||
width: Math.min(det.w, originalWidth - det.x),
|
||||
height: Math.min(det.h, originalHeight - det.y)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图像上绘制检测框
|
||||
*/
|
||||
private drawDetections(canvas: HTMLCanvasElement, detections: Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}>): void {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 为每个类别分配颜色
|
||||
const colors: {[key: string]: string} = {}
|
||||
const colorPalette = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
|
||||
|
||||
detections.forEach((det, idx) => {
|
||||
if (!colors[det.class_name]) {
|
||||
colors[det.class_name] = colorPalette[idx % colorPalette.length]
|
||||
}
|
||||
})
|
||||
|
||||
detections.forEach(det => {
|
||||
const { x, y, width, height } = det.bbox
|
||||
const color = colors[det.class_name]
|
||||
|
||||
// 绘制边界框
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(x, y, width, height)
|
||||
|
||||
// 绘制标签背景
|
||||
const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`
|
||||
ctx.font = '14px Arial'
|
||||
const textMetrics = ctx.measureText(label)
|
||||
const textWidth = textMetrics.width
|
||||
const textHeight = 20
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y - textHeight, textWidth + 10, textHeight)
|
||||
|
||||
// 绘制标签文字
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.fillText(label, x + 5, y - 5)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行检测
|
||||
* @param image 图像元素(Image, Video, 或 Canvas)
|
||||
* @param confidenceThreshold 置信度阈值
|
||||
* @param nmsThreshold NMS阈值
|
||||
*/
|
||||
async detect(
|
||||
image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement,
|
||||
confidenceThreshold: number = 0.25,
|
||||
nmsThreshold: number = 0.7
|
||||
): Promise<YOLODetectionResult> {
|
||||
if (!this.session) {
|
||||
throw new Error('模型未加载,请先调用 loadModel()')
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
// 获取原始图像尺寸
|
||||
const originalWidth = image instanceof HTMLVideoElement ? image.videoWidth : image.width
|
||||
const originalHeight = image instanceof HTMLVideoElement ? image.videoHeight : image.height
|
||||
|
||||
// 预处理图像(letterbox)
|
||||
const prep = this.preprocessImage(image)
|
||||
|
||||
// 创建输入tensor [1, 3, H, W]
|
||||
const inputTensor = new ort.Tensor('float32', prep.input, [1, 3, this.inputShape[1], this.inputShape[0]])
|
||||
|
||||
// 执行推理
|
||||
const inputName = this.session.inputNames[0]
|
||||
const feeds = { [inputName]: inputTensor }
|
||||
const results = await this.session.run(feeds)
|
||||
|
||||
// 获取输出
|
||||
const outputName = this.session.outputNames[0]
|
||||
const output = results[outputName]
|
||||
|
||||
// 后处理
|
||||
const detections = this.postprocess(
|
||||
output,
|
||||
{ ratio: prep.ratio, padX: prep.padX, padY: prep.padY, srcW: originalWidth, srcH: originalHeight },
|
||||
confidenceThreshold,
|
||||
nmsThreshold,
|
||||
{ maxDetections: 100, minBoxArea: originalWidth * originalHeight * 0.0001, classWise: true }
|
||||
)
|
||||
|
||||
// 计算统计信息
|
||||
const objectCount = detections.length
|
||||
const detectedCategories = [...new Set(detections.map(d => d.class_name))]
|
||||
const confidenceScores = detections.map(d => d.confidence)
|
||||
const avgConfidence = confidenceScores.length > 0
|
||||
? confidenceScores.reduce((a, b) => a + b, 0) / confidenceScores.length
|
||||
: 0
|
||||
|
||||
// 绘制检测结果
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = originalWidth
|
||||
canvas.height = originalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0, originalWidth, originalHeight)
|
||||
this.drawDetections(canvas, detections)
|
||||
}
|
||||
|
||||
// 转换为base64(降低质量,减少内存与传输开销)
|
||||
const annotatedImage = canvas.toDataURL('image/jpeg', 0.4)
|
||||
|
||||
const processingTime = (performance.now() - startTime) / 1000
|
||||
|
||||
return {
|
||||
detections: detections.map(d => ({
|
||||
class_name: d.class_name,
|
||||
confidence: d.confidence,
|
||||
bbox: d.bbox
|
||||
})),
|
||||
object_count: objectCount,
|
||||
detected_categories: detectedCategories,
|
||||
confidence_scores: confidenceScores,
|
||||
avg_confidence: avgConfidence,
|
||||
annotated_image: annotatedImage,
|
||||
processing_time: processingTime
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 检测失败:', error)
|
||||
// 若 GPU 后端不支持某些算子,自动回退到 WASM 并重试一次
|
||||
const msg = String((error as any)?.message || error)
|
||||
const needFallback = /GatherND|Unsupported data type|JSF Kernel|ExecuteKernel|WebGPU|WebGL|worker not ready/i.test(msg)
|
||||
if (needFallback && this.currentEP !== 'wasm') {
|
||||
try {
|
||||
console.warn('⚠️ 检测算子不被 GPU 支持,自动回退到 WASM 并重试一次。')
|
||||
// 强制全局与本次调用走 WASM
|
||||
localStorage.setItem('ort_force_wasm','1')
|
||||
await this.loadModel(this.modelPath, this.classNames, 'wasm')
|
||||
// 强制使用 wasm
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
this.currentEP = 'wasm'
|
||||
return await this.detect(image, confidenceThreshold, nmsThreshold)
|
||||
} catch (e2) {
|
||||
console.error('❌ 回退到 WASM 后仍失败:', e2)
|
||||
}
|
||||
}
|
||||
// 如果已是 wasm,但报 worker not ready,再降级为单线程重建 session
|
||||
if (/worker not ready/i.test(msg) && this.currentEP === 'wasm') {
|
||||
try {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = 1
|
||||
await this.loadModel(this.modelPath, this.classNames)
|
||||
return await this.detect(image, confidenceThreshold, nmsThreshold)
|
||||
} catch (e3) {
|
||||
console.error('❌ 降级单线程后仍失败:', e3)
|
||||
}
|
||||
}
|
||||
throw new Error(`检测失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放模型资源
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.session) {
|
||||
// ONNX Runtime会自动管理资源,但我们可以清理引用
|
||||
this.session = null
|
||||
this.modelPath = ''
|
||||
console.log('🗑️ 模型资源已释放')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const yoloDetector = new YOLODetector()
|
||||
505
hertz_server_django_ui/src/views/Home.vue
Normal file
505
hertz_server_django_ui/src/views/Home.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 简约导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">
|
||||
<span class="brand-name">管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navigation">
|
||||
<a href="#home" class="nav-link">首页</a>
|
||||
<a href="#about" class="nav-link">关于</a>
|
||||
<a href="#service" class="nav-link">服务</a>
|
||||
<a href="#contact" class="nav-link">联系</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="btn-login" @click="goToLogin">
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 英雄区域 -->
|
||||
<section class="hero-section" id="home">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">现代化管理系统模板</h1>
|
||||
<p class="hero-description">
|
||||
简洁、高效、易用的后台管理系统解决方案
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn-primary" @click="goToLogin">
|
||||
开始使用
|
||||
</button>
|
||||
<button class="btn-secondary">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 特性区域 -->
|
||||
<section class="features-section" id="service">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">核心特性</h2>
|
||||
<p class="section-subtitle">提供全方位的功能支持</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3 class="feature-title">数据可视化</h3>
|
||||
<p class="feature-description">
|
||||
直观的图表展示,让数据一目了然
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<h3 class="feature-title">权限管理</h3>
|
||||
<p class="feature-description">
|
||||
完善的权限控制体系,保障系统安全
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3 class="feature-title">高性能</h3>
|
||||
<p class="feature-description">
|
||||
优化的架构设计,提供流畅的使用体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3 class="feature-title">响应式设计</h3>
|
||||
<p class="feature-description">
|
||||
完美适配各种设备,随时随地访问
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🎨</div>
|
||||
<h3 class="feature-title">界面美观</h3>
|
||||
<p class="feature-description">
|
||||
现代化的UI设计,提升用户体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔧</div>
|
||||
<h3 class="feature-title">易于扩展</h3>
|
||||
<p class="feature-description">
|
||||
模块化设计,轻松添加新功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 关于区域 -->
|
||||
<section class="about-section" id="about">
|
||||
<div class="container">
|
||||
<div class="about-content">
|
||||
<div class="about-text">
|
||||
<h2 class="about-title">关于系统</h2>
|
||||
<p class="about-description">
|
||||
这是一个现代化的后台管理系统模板,采用最新的技术栈构建,
|
||||
提供完善的功能模块和优雅的用户界面,帮助您快速搭建企业级应用。
|
||||
</p>
|
||||
<ul class="about-features">
|
||||
<li>基于 Vue 3 + TypeScript</li>
|
||||
<li>Ant Design Vue 组件库</li>
|
||||
<li>响应式布局设计</li>
|
||||
<li>完整的权限管理</li>
|
||||
<li>丰富的功能模块</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer" id="contact">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<h3 class="footer-title">管理系统模板</h3>
|
||||
<p class="footer-description">
|
||||
现代化的后台管理系统解决方案
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="link-group">
|
||||
<h4>快速链接</h4>
|
||||
<ul>
|
||||
<li><a href="#" @click="goToLogin">登录系统</a></li>
|
||||
<li><a href="#about">关于我们</a></li>
|
||||
<li><a href="#service">功能介绍</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="link-group">
|
||||
<h4>联系方式</h4>
|
||||
<ul>
|
||||
<li>邮箱:contact@example.com</li>
|
||||
<li>电话:+86 123-4567-8900</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2024 管理系统模板. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
// 导航栏
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
.logo-text {
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.nav-link {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.btn-login {
|
||||
padding: 8px 20px;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 英雄区域
|
||||
.hero-section {
|
||||
padding: 160px 24px 120px;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 48px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.btn-primary {
|
||||
padding: 14px 32px;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 14px 32px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
color: #374151;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 特性区域
|
||||
.features-section {
|
||||
padding: 80px 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: #ffffff;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 关于区域
|
||||
.about-section {
|
||||
padding: 80px 24px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.about-description {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.about-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding: 8px 0;
|
||||
color: #374151;
|
||||
font-size: 15px;
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚
|
||||
.footer {
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 64px 24px 32px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
.footer-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.footer-description {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
|
||||
.link-group {
|
||||
h4 {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
|
||||
a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
464
hertz_server_django_ui/src/views/Login.vue
Normal file
464
hertz_server_django_ui/src/views/Login.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧区域 -->
|
||||
<div class="left-section">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">欢迎使用</h1>
|
||||
<h2 class="system-name">管理系统模板</h2>
|
||||
<p class="welcome-description">
|
||||
现代化的后台管理系统解决方案
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录表单 -->
|
||||
<div class="right-section">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">{{ $t('login.title') }}</h1>
|
||||
<p class="login-subtitle">请输入您的登录信息</p>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@finish="handleLogin"
|
||||
layout="vertical"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item
|
||||
:label="$t('login.username')"
|
||||
name="username"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
:placeholder="$t('login.username')"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:label="$t('login.password')"
|
||||
name="password"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
:placeholder="$t('login.password')"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="验证码"
|
||||
name="captcha"
|
||||
>
|
||||
<a-row :gutter="8">
|
||||
<a-col :span="14">
|
||||
<a-input
|
||||
v-model:value="form.captcha"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<SafetyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :span="10">
|
||||
<div class="captcha-container">
|
||||
<img
|
||||
v-if="captchaData?.image_data"
|
||||
:src="captchaData.image_data"
|
||||
alt="验证码"
|
||||
class="captcha-image"
|
||||
@click="handleRefreshCaptcha"
|
||||
/>
|
||||
<a-button
|
||||
v-else
|
||||
size="large"
|
||||
:loading="captchaLoading"
|
||||
@click="handleRefreshCaptcha"
|
||||
block
|
||||
>
|
||||
获取验证码
|
||||
</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="login-options">
|
||||
<a-checkbox v-model:checked="form.remember">
|
||||
{{ $t('login.rememberMe') }}
|
||||
</a-checkbox>
|
||||
<a href="#" class="forgot-password">{{ $t('login.forgotPassword') }}</a>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
block
|
||||
class="login-button"
|
||||
>
|
||||
{{ $t('login.login') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<div class="register-link">
|
||||
还没有账户?
|
||||
<a @click="goToRegister">立即注册</a>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/hertz_user'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
SafetyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useCaptcha } from '@/utils/hertz_captcha'
|
||||
import { loginUser } from '@/api'
|
||||
import { errorHandler, handleSuccess } from '@/utils/hertz_error_handler'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 初始化错误处理器的i18n实例
|
||||
errorHandler.setI18n({ t })
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const { captchaData, loading: captchaLoading, generateCaptcha, refreshCaptcha } = useCaptcha()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: t('error.usernameRequired'), trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: t('error.passwordRequired'), trigger: 'blur' },
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: t('error.captchaRequired'), trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loading.value) return
|
||||
|
||||
// 验证表单
|
||||
if (!form.username || !form.password || !form.captcha) {
|
||||
message.error(t('error.requiredFieldMissing'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查验证码数据是否存在
|
||||
if (!captchaData.value?.captcha_id) {
|
||||
message.error(t('error.captchaExpired'))
|
||||
await handleRefreshCaptcha()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 构建登录数据 - 严格按照API接口定义
|
||||
const loginData = {
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
captcha_code: form.captcha.trim(),
|
||||
captcha_key: captchaData.value.captcha_id
|
||||
}
|
||||
|
||||
const response = await loginUser(loginData)
|
||||
|
||||
// 设置用户状态到store
|
||||
if (response.data) {
|
||||
// 设置token - 使用后端返回的access_token
|
||||
if (response.data.access_token) {
|
||||
userStore.token = response.data.access_token
|
||||
localStorage.setItem('token', response.data.access_token)
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
if (response.data.user_info) {
|
||||
userStore.userInfo = response.data.user_info
|
||||
userStore.isLoggedIn = true
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.user_info))
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccess('login')
|
||||
|
||||
// 根据用户角色跳转到对应首页
|
||||
const userRole = response.data?.user_info?.roles?.[0]?.role_code
|
||||
|
||||
// 仅管理员角色进入管理端,其余(含未定义)进入用户端
|
||||
const adminRoles = ['admin', 'system_admin', 'super_admin']
|
||||
const isAdmin = adminRoles.includes(userRole as any)
|
||||
if (isAdmin) {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
// 清除敏感字段
|
||||
form.password = ''
|
||||
form.captcha = ''
|
||||
|
||||
// 刷新验证码
|
||||
await handleRefreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshCaptcha = async () => {
|
||||
try {
|
||||
await refreshCaptcha()
|
||||
// 清空验证码输入
|
||||
form.captcha = ''
|
||||
} catch (error) {
|
||||
message.error('刷新验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToRegister = () => {
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
// 页面加载时生成验证码
|
||||
onMounted(() => {
|
||||
generateCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.left-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.system-name {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.welcome-description {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 右侧登录表单 */
|
||||
.right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #ffffff;
|
||||
padding: 48px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper:focus),
|
||||
:deep(.ant-input-affix-wrapper-focused) {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-prefix) {
|
||||
color: #9ca3af;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:hover) {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-checked .ant-checkbox-inner) {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.captcha-container {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
</style>
|
||||
149
hertz_server_django_ui/src/views/ModuleSetup.vue
Normal file
149
hertz_server_django_ui/src/views/ModuleSetup.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="module-setup">
|
||||
<a-card title="功能模块配置" class="module-setup-card">
|
||||
<div class="module-setup-intro">
|
||||
<p class="intro-title">使用说明</p>
|
||||
<p>1)本页面用于功能模块 DIY:勾选需要启用的模块,未勾选的模块将在菜单和路由中隐藏,仅作为运行时屏蔽,不影响源码文件。</p>
|
||||
<p>2)配置保存成功后,选择结果会以 <code>hertz_enabled_modules</code> 的形式保存在浏览器 Local Storage 中,下次执行 <code>npm run dev</code> 时如果存在该记录,将直接进入系统而不再展示本配置页。</p>
|
||||
<p>3)如需重新调整模块,请打开浏览器开发者工具 → <strong>Application</strong> → <strong>Local Storage</strong> → 选择当前站点,删除键 <code>hertz_enabled_modules</code>,然后刷新页面即可重新回到本页重新选择。</p>
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="一键裁剪(可选)"
|
||||
description="在本页确认模块选择并关闭运行环境后,可在终端运行 npm run prune,按提示对未勾选模块进行一键裁剪(支持仅屏蔽或直接删除相关页面)。"
|
||||
/>
|
||||
</div>
|
||||
<a-tabs v-model:activeKey="activeKey">
|
||||
<a-tab-pane key="admin" tab="管理端模块">
|
||||
<a-checkbox-group v-model:value="adminSelected" class="module-group">
|
||||
<div
|
||||
v-for="m in adminModules"
|
||||
:key="m.key"
|
||||
class="module-item"
|
||||
>
|
||||
<a-checkbox :value="m.key">
|
||||
<span class="module-label">{{ m.label }}</span>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="user" tab="用户端模块">
|
||||
<a-checkbox-group v-model:value="userSelected" class="module-group">
|
||||
<div
|
||||
v-for="m in userModules"
|
||||
:key="m.key"
|
||||
class="module-item"
|
||||
>
|
||||
<a-checkbox :value="m.key">
|
||||
<span class="module-label">{{ m.label }}</span>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
<div class="module-setup-actions">
|
||||
<a-button style="margin-right: 8px" @click="resetToDefault">恢复默认</a-button>
|
||||
<a-button style="margin-right: 8px" @click="saveModules">保存配置并刷新</a-button>
|
||||
<a-button type="primary" @click="saveModulesAndGoLogin">保存并跳转登录</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
HERTZ_MODULES,
|
||||
getEnabledModuleKeys,
|
||||
getModulesByGroup,
|
||||
setEnabledModuleKeys,
|
||||
type HertzModuleGroup,
|
||||
} from '@/config/hertz_modules'
|
||||
|
||||
const activeKey = ref<HertzModuleGroup>('admin')
|
||||
|
||||
const adminModules = computed(() => getModulesByGroup('admin'))
|
||||
const userModules = computed(() => getModulesByGroup('user'))
|
||||
|
||||
const adminSelected = ref<string[]>([])
|
||||
const userSelected = ref<string[]>([])
|
||||
|
||||
const loadCurrentSelection = () => {
|
||||
const enabled = getEnabledModuleKeys()
|
||||
adminSelected.value = enabled.filter(k => k.indexOf('admin.') === 0)
|
||||
userSelected.value = enabled.filter(k => k.indexOf('user.') === 0)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
const defaultEnabled = HERTZ_MODULES.filter(m => m.defaultEnabled).map(m => m.key)
|
||||
setEnabledModuleKeys(defaultEnabled)
|
||||
loadCurrentSelection()
|
||||
}
|
||||
|
||||
const saveModules = () => {
|
||||
const merged = adminSelected.value.concat(userSelected.value)
|
||||
setEnabledModuleKeys(merged)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const saveModulesAndGoLogin = () => {
|
||||
const merged = adminSelected.value.concat(userSelected.value)
|
||||
setEnabledModuleKeys(merged)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCurrentSelection()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.module-setup {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.module-setup-card {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.module-setup-intro {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.module-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.module-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.module-setup-actions {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
65
hertz_server_django_ui/src/views/NotFound.vue
Normal file
65
hertz_server_django_ui/src/views/NotFound.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<h1>404</h1>
|
||||
<h2>{{ $t('error.404') }}</h2>
|
||||
<p>抱歉,您访问的页面不存在</p>
|
||||
<router-link to="/" class="back-home-btn">
|
||||
返回首页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 404页面不需要额外的逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.not-found-content h1 {
|
||||
font-size: 72px;
|
||||
margin: 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.not-found-content h2 {
|
||||
font-size: 24px;
|
||||
margin: 16px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.not-found-content p {
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-home-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.back-home-btn:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
</style>
|
||||
1046
hertz_server_django_ui/src/views/admin_page/AlertLevelManagement.vue
Normal file
1046
hertz_server_django_ui/src/views/admin_page/AlertLevelManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1369
hertz_server_django_ui/src/views/admin_page/ArticleManagement.vue
Normal file
1369
hertz_server_django_ui/src/views/admin_page/ArticleManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
1536
hertz_server_django_ui/src/views/admin_page/Dashboard.vue
Normal file
1536
hertz_server_django_ui/src/views/admin_page/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
1404
hertz_server_django_ui/src/views/admin_page/DatasetManagement.vue
Normal file
1404
hertz_server_django_ui/src/views/admin_page/DatasetManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,989 @@
|
||||
<template>
|
||||
<div class="department-management">
|
||||
<!-- 页面头部 - 苹果风格 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-icon-wrapper">
|
||||
<ApartmentOutlined class="header-icon" />
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1 class="page-title">部门管理</h1>
|
||||
<p class="page-description">管理组织架构和部门信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 - 苹果风格 -->
|
||||
<div class="action-bar">
|
||||
<div class="button-section">
|
||||
<a-button type="primary" @click="handleAdd" class="action-btn-primary">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加部门
|
||||
</a-button>
|
||||
<a-button @click="refreshData" class="action-btn-secondary">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="expandAll" class="action-btn-secondary">
|
||||
<template #icon><ExpandAltOutlined /></template>
|
||||
展开全部
|
||||
</a-button>
|
||||
<a-button @click="collapseAll" class="action-btn-secondary">
|
||||
<template #icon><ShrinkOutlined /></template>
|
||||
收起全部
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 部门树形表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="paginatedData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:expanded-row-keys="expandedKeys"
|
||||
@expand="onExpand"
|
||||
@change="handleTableChange"
|
||||
row-key="dept_id"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 部门名称列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dept_name'">
|
||||
<div class="dept-name-container">
|
||||
<span
|
||||
v-if="hasChildren(record)"
|
||||
class="toggle-icon"
|
||||
@click.stop="toggleChildren(record)"
|
||||
>
|
||||
<CaretDownOutlined v-if="isExpanded(record.dept_id)" />
|
||||
<CaretRightOutlined v-else />
|
||||
</span>
|
||||
<span class="dept-name">{{ record.dept_name }}</span>
|
||||
<span v-if="hasChildren(record)" class="children-count">({{ getChildrenCount(record) }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record.dept_id)">
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleAddChild(record)">
|
||||
添加子部门
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个部门吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.dept_id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑部门弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="modalVisible"
|
||||
:title="modalMode === 'add' ? '添加部门' : '编辑部门'"
|
||||
:confirm-loading="modalLoading"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="上级部门" name="parent_id">
|
||||
<a-tree-select
|
||||
v-model:value="formData.parent_id"
|
||||
:tree-data="departmentTreeOptions"
|
||||
placeholder="请选择上级部门"
|
||||
tree-default-expand-all
|
||||
:field-names="{ children: 'children', label: 'dept_name', value: 'dept_id' }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="部门名称" name="dept_name">
|
||||
<a-input v-model:value="formData.dept_name" placeholder="请输入部门名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="部门编码" name="dept_code">
|
||||
<a-input v-model:value="formData.dept_code" placeholder="请输入部门编码" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="负责人" name="leader">
|
||||
<a-input v-model:value="formData.leader" placeholder="请输入负责人姓名" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="formData.email" placeholder="请输入邮箱地址" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序" name="sort_order">
|
||||
<a-input-number
|
||||
v-model:value="formData.sort_order"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
placeholder="请输入排序值"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 部门详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="detailVisible"
|
||||
title="部门详情"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="部门ID">
|
||||
{{ departmentDetail?.dept_id }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="部门名称">
|
||||
{{ departmentDetail?.dept_name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="部门编码">
|
||||
{{ departmentDetail?.dept_code }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上级部门ID">
|
||||
{{ departmentDetail?.parent_id || '无' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="负责人">
|
||||
{{ departmentDetail?.leader }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">
|
||||
{{ departmentDetail?.phone || '未设置' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">
|
||||
{{ departmentDetail?.email || '未设置' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="departmentDetail?.status === 1 ? 'green' : 'red'">
|
||||
{{ departmentDetail?.status === 1 ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="排序">
|
||||
{{ departmentDetail?.sort_order }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户数量">
|
||||
{{ departmentDetail?.user_count || 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">
|
||||
{{ departmentDetail?.created_at ? new Date(departmentDetail.created_at).toLocaleString() : '' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间" :span="2">
|
||||
{{ departmentDetail?.updated_at ? new Date(departmentDetail.updated_at).toLocaleString() : '' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ExpandAltOutlined,
|
||||
ShrinkOutlined,
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
ApartmentOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { departmentApi, type Department, type CreateDepartmentParams } from '@/api'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const modalMode = ref<'add' | 'edit'>('add')
|
||||
const formRef = ref<FormInstance>()
|
||||
const expandedKeys = ref<number[]>([])
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
hideOnSinglePage: false,
|
||||
})
|
||||
|
||||
// 判断部门是否有子部门
|
||||
const hasChildren = (dept: Department): boolean => {
|
||||
return dept.children && dept.children.length > 0
|
||||
}
|
||||
|
||||
// 获取子部门数量
|
||||
const getChildrenCount = (dept: Department): number => {
|
||||
return dept.children ? dept.children.length : 0
|
||||
}
|
||||
|
||||
// 判断部门是否展开
|
||||
const isExpanded = (deptId: number): boolean => {
|
||||
return expandedKeys.value.includes(deptId)
|
||||
}
|
||||
|
||||
// 切换部门折叠状态
|
||||
const toggleChildren = (dept: Department) => {
|
||||
const index = expandedKeys.value.indexOf(dept.dept_id)
|
||||
if (index > -1) {
|
||||
// 如果已展开,则折叠
|
||||
expandedKeys.value.splice(index, 1)
|
||||
} else {
|
||||
// 如果已折叠,则展开
|
||||
expandedKeys.value.push(dept.dept_id)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedData = computed(() => {
|
||||
const startIndex = (pagination.current - 1) * pagination.pageSize;
|
||||
const endIndex = startIndex + pagination.pageSize;
|
||||
pagination.total = departmentTree.value.length;
|
||||
return departmentTree.value.slice(startIndex, endIndex);
|
||||
})
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current;
|
||||
pagination.pageSize = pag.pageSize;
|
||||
}
|
||||
|
||||
// 部门详情相关
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const departmentDetail = ref<Department | null>(null)
|
||||
|
||||
// 部门数据
|
||||
const departmentList = ref<Department[]>([])
|
||||
const departmentTree = ref<Department[]>([])
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<CreateDepartmentParams>({
|
||||
parent_id: null,
|
||||
dept_name: '',
|
||||
dept_code: '',
|
||||
leader: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
status: 1,
|
||||
sort_order: 0
|
||||
})
|
||||
|
||||
// 当前编辑的部门ID
|
||||
const currentEditId = ref<number | null>(null)
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '部门名称',
|
||||
dataIndex: 'dept_name',
|
||||
key: 'dept_name',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '部门编码',
|
||||
dataIndex: 'dept_code',
|
||||
key: 'dept_code'
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'leader',
|
||||
key: 'leader'
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone'
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = computed(() => ({
|
||||
dept_name: [
|
||||
{ required: true, message: '请输入部门名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '部门名称长度在2-50个字符', trigger: 'blur' }
|
||||
],
|
||||
dept_code: [
|
||||
{ required: true, message: '请输入部门编码', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '部门编码长度在2-20个字符', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '部门编码只能包含字母、数字、下划线和横线', trigger: 'blur' }
|
||||
],
|
||||
leader: [
|
||||
{ max: 20, message: '负责人姓名不能超过20个字符', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
],
|
||||
sort_order: [
|
||||
{ required: true, message: '请输入排序值', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, max: 9999, message: '排序值范围为0-9999', trigger: 'blur' }
|
||||
]
|
||||
}))
|
||||
|
||||
// 部门树选择器选项
|
||||
const departmentTreeOptions = computed(() => {
|
||||
const addRootOption = (tree: Department[]): Department[] => {
|
||||
return [
|
||||
{ dept_id: 0, dept_name: '根部门', children: tree } as Department,
|
||||
...tree
|
||||
]
|
||||
}
|
||||
return addRootOption(departmentTree.value)
|
||||
})
|
||||
|
||||
// 获取部门树数据
|
||||
const fetchDepartmentTree = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await departmentApi.getDepartmentList()
|
||||
if (response.success) {
|
||||
// 后端直接返回树形结构
|
||||
departmentTree.value = response.data
|
||||
// 默认展开第一级
|
||||
expandedKeys.value = departmentTree.value.map(item => item.dept_id)
|
||||
} else {
|
||||
message.error(response.message || '获取部门数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取部门数据失败:', error)
|
||||
message.error('获取部门数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchDepartmentTree()
|
||||
}
|
||||
|
||||
// 展开/收起处理
|
||||
const onExpand = (expanded: boolean, record: Department) => {
|
||||
if (expanded) {
|
||||
expandedKeys.value.push(record.dept_id)
|
||||
} else {
|
||||
const index = expandedKeys.value.indexOf(record.dept_id)
|
||||
if (index > -1) {
|
||||
expandedKeys.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 展开全部
|
||||
const expandAll = () => {
|
||||
const getAllKeys = (tree: Department[]): number[] => {
|
||||
let keys: number[] = []
|
||||
tree.forEach(item => {
|
||||
keys.push(item.dept_id)
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys = keys.concat(getAllKeys(item.children))
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
expandedKeys.value = getAllKeys(departmentTree.value)
|
||||
}
|
||||
|
||||
// 收起全部
|
||||
const collapseAll = () => {
|
||||
expandedKeys.value = []
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
parent_id: 0,
|
||||
dept_name: '',
|
||||
dept_code: '',
|
||||
leader: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
status: 1,
|
||||
sort_order: 0
|
||||
})
|
||||
currentEditId.value = null
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 查看部门详情
|
||||
const handleViewDetail = async (deptId: number) => {
|
||||
console.log('点击查看详情,部门ID:', deptId)
|
||||
try {
|
||||
detailLoading.value = true
|
||||
detailVisible.value = true
|
||||
console.log('弹窗状态设置为:', detailVisible.value)
|
||||
const response = await departmentApi.getDepartment(deptId)
|
||||
if (response.success) {
|
||||
departmentDetail.value = response.data
|
||||
console.log('获取部门详情成功:', response.data)
|
||||
} else {
|
||||
message.error(response.message || '获取部门详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取部门详情失败:', error)
|
||||
message.error('获取部门详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加部门
|
||||
const handleAdd = () => {
|
||||
resetForm()
|
||||
modalMode.value = 'add'
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 添加子部门
|
||||
const handleAddChild = (parent: Department) => {
|
||||
resetForm()
|
||||
formData.parent_id = parent.dept_id
|
||||
modalMode.value = 'add'
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑部门
|
||||
const handleEdit = (record: Department) => {
|
||||
console.log('点击编辑部门,部门信息:', record)
|
||||
resetForm()
|
||||
Object.assign(formData, {
|
||||
parent_id: record.parent_id,
|
||||
dept_name: record.dept_name,
|
||||
dept_code: record.dept_code,
|
||||
leader: record.leader,
|
||||
phone: record.phone,
|
||||
email: record.email,
|
||||
status: record.status,
|
||||
sort_order: record.sort_order
|
||||
})
|
||||
currentEditId.value = record.dept_id
|
||||
modalMode.value = 'edit'
|
||||
modalVisible.value = true
|
||||
console.log('编辑弹窗状态设置为:', modalVisible.value)
|
||||
console.log('表单数据:', formData)
|
||||
}
|
||||
|
||||
// 删除部门
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await departmentApi.deleteDepartment(id)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
await fetchDepartmentTree()
|
||||
} else {
|
||||
message.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除部门失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗确定
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
modalLoading.value = true
|
||||
|
||||
// 确保数据类型正确,符合后端要求
|
||||
const submitData = {
|
||||
parent_id: Number(formData.parent_id) || 0,
|
||||
dept_name: String(formData.dept_name || '').trim(),
|
||||
dept_code: String(formData.dept_code || '').trim(),
|
||||
leader: String(formData.leader || '').trim(),
|
||||
phone: String(formData.phone || '').trim(),
|
||||
email: String(formData.email || '').trim(),
|
||||
status: Number(formData.status) || 0,
|
||||
sort_order: Number(formData.sort_order) || 0
|
||||
}
|
||||
|
||||
console.log('提交数据:', submitData)
|
||||
console.log('操作模式:', modalMode.value)
|
||||
|
||||
let response
|
||||
if (modalMode.value === 'add') {
|
||||
response = await departmentApi.createDepartment(submitData)
|
||||
} else {
|
||||
response = await departmentApi.updateDepartment(currentEditId.value!, submitData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(modalMode.value === 'add' ? '添加成功' : '更新成功')
|
||||
modalVisible.value = false
|
||||
await fetchDepartmentTree()
|
||||
} else {
|
||||
message.error(response.message || (modalMode.value === 'add' ? '添加失败' : '更新失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
modalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗取消
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchDepartmentTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.department-management {
|
||||
padding: 0;
|
||||
background: #f5f5f7;
|
||||
min-height: 100vh;
|
||||
|
||||
// 页面头部 - 苹果风格
|
||||
.page-header {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
padding: 32px 28px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.header-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 24px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #86868b;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作栏 - 苹果风格
|
||||
.action-bar {
|
||||
margin: 0 28px 24px 28px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.button-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.action-btn-primary {
|
||||
border-radius: 12px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-secondary {
|
||||
border-radius: 12px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
color: #1d1d1f;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格容器 - 苹果风格
|
||||
.table-container {
|
||||
margin: 0 28px 24px 28px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
background: transparent;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
> td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dept-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.toggle-icon {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
color: #3b82f6;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.dept-name {
|
||||
font-weight: 500;
|
||||
color: #1d1d1f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.children-count {
|
||||
margin-left: 8px;
|
||||
color: #86868b;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页器样式优化 - 苹果风格
|
||||
:deep(.ant-pagination) {
|
||||
margin: 20px 24px;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
color: #86868b;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
margin: 0 4px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
&.ant-pagination-item-active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
|
||||
a {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
border-radius: 8px;
|
||||
margin: 0 8px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-jump-prev,
|
||||
.ant-pagination-jump-next {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
margin: 0 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options-quick-jumper {
|
||||
margin-left: 16px;
|
||||
color: #86868b;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// 弹窗样式已由全局样式统一处理
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.department-management {
|
||||
.page-header,
|
||||
.action-bar,
|
||||
.table-container {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.department-management {
|
||||
.page-header {
|
||||
padding: 24px 16px;
|
||||
|
||||
.header-content {
|
||||
.header-icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin: 0 16px 20px 16px;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.button-section {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.action-btn-primary,
|
||||
.action-btn-secondary {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin: 0 16px 20px 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
1221
hertz_server_django_ui/src/views/admin_page/LogManagement.vue
Normal file
1221
hertz_server_django_ui/src/views/admin_page/LogManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
1199
hertz_server_django_ui/src/views/admin_page/MenuManagement.vue
Normal file
1199
hertz_server_django_ui/src/views/admin_page/MenuManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
1832
hertz_server_django_ui/src/views/admin_page/ModelManagement.vue
Normal file
1832
hertz_server_django_ui/src/views/admin_page/ModelManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1679
hertz_server_django_ui/src/views/admin_page/Role.vue
Normal file
1679
hertz_server_django_ui/src/views/admin_page/Role.vue
Normal file
File diff suppressed because it is too large
Load Diff
1571
hertz_server_django_ui/src/views/admin_page/UserManagement.vue
Normal file
1571
hertz_server_django_ui/src/views/admin_page/UserManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
1305
hertz_server_django_ui/src/views/admin_page/YoloTrainManagement.vue
Normal file
1305
hertz_server_django_ui/src/views/admin_page/YoloTrainManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
1689
hertz_server_django_ui/src/views/admin_page/index.vue
Normal file
1689
hertz_server_django_ui/src/views/admin_page/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
383
hertz_server_django_ui/src/views/register.vue
Normal file
383
hertz_server_django_ui/src/views/register.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<!-- 左侧区域 -->
|
||||
<div class="left-section">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">创建账户</h1>
|
||||
<h2 class="system-name">管理系统模板</h2>
|
||||
<p class="welcome-description">
|
||||
填写注册信息,开始使用系统
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧注册表单 -->
|
||||
<div class="right-section">
|
||||
<div class="register-card">
|
||||
<div class="register-header">
|
||||
<h1 class="register-title">{{ $t('register.title') }}</h1>
|
||||
<p class="register-subtitle">请填写注册信息</p>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@finish="handleRegister"
|
||||
layout="vertical"
|
||||
class="register-form"
|
||||
>
|
||||
<a-form-item
|
||||
label="用户名"
|
||||
name="username"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="真实姓名" name="real_name">
|
||||
<a-input
|
||||
v-model:value="form.real_name"
|
||||
placeholder="请输入真实姓名"
|
||||
size="large"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="确认密码"
|
||||
name="confirmPassword"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="form.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
block
|
||||
class="register-button"
|
||||
>
|
||||
注册
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<div class="login-link">
|
||||
已有账户?
|
||||
<a @click="goToLogin">立即登录</a>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
IdcardOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { registerUser } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
real_name: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
real_name: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
if (value !== form.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
confirm_password: form.confirmPassword,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
real_name: form.real_name,
|
||||
// 后端未启用验证码时传空串,保持字段兼容
|
||||
captcha: '',
|
||||
captcha_id: '',
|
||||
}
|
||||
|
||||
await registerUser(payload as any)
|
||||
message.success('注册成功')
|
||||
router.push('/login')
|
||||
} catch (error: any) {
|
||||
const detail = error?.response?.data
|
||||
if (detail) {
|
||||
const msg = detail.message || detail.detail || '注册失败'
|
||||
message.error(msg)
|
||||
} else {
|
||||
message.error('注册失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* 左侧区域 */
|
||||
.left-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.system-name {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.welcome-description {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 右侧注册表单 */
|
||||
.right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
background: #ffffff;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: #ffffff;
|
||||
padding: 48px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.register-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.register-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper:focus),
|
||||
:deep(.ant-input-affix-wrapper-focused) {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-prefix) {
|
||||
color: #9ca3af;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:hover) {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
1639
hertz_server_django_ui/src/views/user_pages/AiChat.vue
Normal file
1639
hertz_server_django_ui/src/views/user_pages/AiChat.vue
Normal file
File diff suppressed because it is too large
Load Diff
1102
hertz_server_django_ui/src/views/user_pages/AlertCenter.vue
Normal file
1102
hertz_server_django_ui/src/views/user_pages/AlertCenter.vue
Normal file
File diff suppressed because it is too large
Load Diff
625
hertz_server_django_ui/src/views/user_pages/ArticleCenter.vue
Normal file
625
hertz_server_django_ui/src/views/user_pages/ArticleCenter.vue
Normal file
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<div class="knowledge-center">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
<BookOutlined class="title-icon" />
|
||||
文章中心
|
||||
</h1>
|
||||
<p class="page-description">探索智能知识,提升工作效率</p>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-section">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ pagination.total }}</div>
|
||||
<div class="stat-label">文章总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ categoryTree.length }}</div>
|
||||
<div class="stat-label">分类数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-row">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 左侧分类树 -->
|
||||
<a-col :xs="24" :md="6">
|
||||
<div class="category-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon">
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h3 class="panel-title">文章分类</h3>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<a-spin :spinning="categoryLoading">
|
||||
<a-tree
|
||||
:tree-data="categoryTree"
|
||||
:field-names="{ title: 'name', key: 'id', children: 'children' }"
|
||||
default-expand-all
|
||||
@select="onCategorySelect"
|
||||
class="tech-tree"
|
||||
/>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧文章列表 -->
|
||||
<a-col :xs="24" :md="18">
|
||||
<div class="articles-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<div class="panel-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div class="title-group">
|
||||
<h3 class="panel-title">文章列表</h3>
|
||||
<span class="panel-subtitle">发现更多精彩内容</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-section">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索文章标题或标签..."
|
||||
class="tech-search"
|
||||
@search="handleSearchImmediate"
|
||||
@input="handleSearch"
|
||||
allow-clear
|
||||
:loading="loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="search-icon" />
|
||||
</template>
|
||||
<template #enterButton>
|
||||
<a-button type="primary" class="search-btn">
|
||||
<SearchOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input-search>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="articles-grid" v-if="articleList.length > 0">
|
||||
<div
|
||||
v-for="(item, index) in articleList"
|
||||
:key="item.id"
|
||||
class="article-card"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="article-status">
|
||||
<a-tag color="success" class="status-tag">
|
||||
<CheckCircleOutlined />
|
||||
已发布
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="article-views" v-if="item.view_count">
|
||||
<EyeOutlined />
|
||||
<span>{{ item.view_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<h4 class="article-title">{{ item.title }}</h4>
|
||||
<p class="article-summary" v-if="item.summary">{{ item.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="article-meta">
|
||||
<div class="meta-item">
|
||||
<BookOutlined />
|
||||
<span>{{ item.category_name }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<UserOutlined />
|
||||
<span>{{ item.author_name }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<ClockCircleOutlined />
|
||||
<span>{{ formatDate(item.published_at || item.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-more">
|
||||
<ReadOutlined />
|
||||
<span>阅读全文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-empty v-else description="暂无文章数据" class="empty-state">
|
||||
<template #image>
|
||||
<FileSearchOutlined style="font-size: 64px; color: #d9d9d9;" />
|
||||
</template>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="pagination.total > 0">
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:show-size-changer="true"
|
||||
:show-quick-jumper="true"
|
||||
:show-total="(total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`"
|
||||
@change="pagination.onChange"
|
||||
class="tech-pagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
BookOutlined,
|
||||
FileTextOutlined,
|
||||
EyeOutlined,
|
||||
ReadOutlined,
|
||||
SearchOutlined,
|
||||
CheckCircleOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileSearchOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { knowledgeApi, type KnowledgeArticleListItem, type KnowledgeArticleDetail, type KnowledgeCategory } from '@/api/knowledge'
|
||||
|
||||
const loading = ref(false)
|
||||
const categoryLoading = ref(false)
|
||||
const searchText = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
const categoryTree = ref<KnowledgeCategory[]>([])
|
||||
const selectedCategoryId = ref<number | undefined>(undefined)
|
||||
|
||||
const articleList = ref<KnowledgeArticleListItem[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
onChange: (page: number, pageSize: number) => {
|
||||
pagination.current = page
|
||||
pagination.pageSize = pageSize
|
||||
fetchArticles()
|
||||
}
|
||||
})
|
||||
|
||||
// 详情页改为独立路由展示
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
categoryLoading.value = true
|
||||
const res = await knowledgeApi.getCategoryTree()
|
||||
categoryTree.value = (res.data || []).filter((c: any) => c.is_active !== false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
categoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await knowledgeApi.getArticles({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
title: searchText.value || undefined,
|
||||
category_id: selectedCategoryId.value,
|
||||
status: 'published'
|
||||
})
|
||||
const data = res.data
|
||||
articleList.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchImmediate = () => {
|
||||
pagination.current = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 防抖可选,这里直接刷新列表
|
||||
pagination.current = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
const onCategorySelect = (keys: (string | number)[]) => {
|
||||
selectedCategoryId.value = (keys[0] as number) || undefined
|
||||
pagination.current = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
try {
|
||||
const res = await knowledgeApi.getArticle(id)
|
||||
const detail = res.data
|
||||
if (detail.status !== 'published') {
|
||||
message.warning('该文章未发布,无法查看')
|
||||
return
|
||||
}
|
||||
router.push(`/user/knowledge/${id}`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchArticles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables';
|
||||
|
||||
.knowledge-center {
|
||||
padding: 24px;
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
min-height: 100vh;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.title-icon {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stats-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.stat-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--theme-text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-row {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// 分类面板
|
||||
.category-panel {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
|
||||
.panel-header {
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.panel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
|
||||
.tech-tree {
|
||||
:deep(.ant-tree-node-content-wrapper) {
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-content-bg, #eff6ff);
|
||||
}
|
||||
|
||||
&.ant-tree-node-selected {
|
||||
background: var(--theme-content-bg, #eff6ff);
|
||||
color: var(--theme-primary, #2563eb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章面板
|
||||
.articles-panel {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
|
||||
.panel-header {
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.panel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
.tech-search {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 24px;
|
||||
|
||||
.articles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.article-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border-color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 20px 20px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.article-status {
|
||||
.status-tag {
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.article-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px 20px;
|
||||
|
||||
.article-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 0 20px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--theme-text-secondary, #94a3b8);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
:deep(.ant-empty-description) {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
671
hertz_server_django_ui/src/views/user_pages/ArticleDetail.vue
Normal file
671
hertz_server_django_ui/src/views/user_pages/ArticleDetail.vue
Normal file
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<div class="knowledge-detail">
|
||||
<!-- 科技风头部导航 -->
|
||||
<div class="tech-header animate-fade-in-up">
|
||||
<div class="header-bg">
|
||||
<div class="tech-pattern"></div>
|
||||
<div class="gradient-overlay"></div>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<div class="nav-actions">
|
||||
<a-button @click="goBack" class="nav-btn back-btn">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回列表
|
||||
</a-button>
|
||||
<a-button @click="copyLink" class="nav-btn copy-btn">
|
||||
<template #icon><LinkOutlined /></template>
|
||||
复制链接
|
||||
</a-button>
|
||||
<a-button @click="printPage" class="nav-btn print-btn">
|
||||
<template #icon><PrinterOutlined /></template>
|
||||
打印文章
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容区域 -->
|
||||
<div class="article-container">
|
||||
<div class="article-wrapper animate-fade-in-up" style="animation-delay: 0.2s;">
|
||||
<!-- 文章头部信息 -->
|
||||
<div class="article-header">
|
||||
<div class="article-status">
|
||||
<a-tag color="success" class="status-badge" v-if="detail?.status === 'published'">
|
||||
<CheckCircleOutlined />
|
||||
已发布
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<h1 class="article-title">{{ detail?.title || '加载中...' }}</h1>
|
||||
|
||||
<div class="article-meta">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<div class="meta-icon">
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">分类</span>
|
||||
<span class="meta-value">{{ detail?.category_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-icon">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">作者</span>
|
||||
<span class="meta-value">{{ detail?.author_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item">
|
||||
<div class="meta-icon">
|
||||
<CalendarOutlined />
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">发布时间</span>
|
||||
<span class="meta-value">{{ formatDate(detail?.published_at || detail?.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-item" v-if="detail?.view_count">
|
||||
<div class="meta-icon">
|
||||
<EyeOutlined />
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">浏览次数</span>
|
||||
<span class="meta-value">{{ detail.view_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-tags" v-if="detail?.tags_list?.length">
|
||||
<div class="tags-label">标签</div>
|
||||
<div class="tags-list">
|
||||
<a-tag
|
||||
v-for="tag in detail.tags_list"
|
||||
:key="tag"
|
||||
class="tech-tag"
|
||||
>
|
||||
<TagOutlined />
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="article-content">
|
||||
<a-spin :spinning="loading" size="large">
|
||||
<div class="content-wrapper" v-html="detail?.content"></div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 文章底部操作 -->
|
||||
<div class="article-footer">
|
||||
<div class="footer-actions">
|
||||
<a-button @click="goBack" class="action-btn">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回列表
|
||||
</a-button>
|
||||
<a-button @click="copyLink" class="action-btn">
|
||||
<template #icon><ShareAltOutlined /></template>
|
||||
分享文章
|
||||
</a-button>
|
||||
<a-button @click="printPage" class="action-btn">
|
||||
<template #icon><PrinterOutlined /></template>
|
||||
打印
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
LinkOutlined,
|
||||
PrinterOutlined,
|
||||
CheckCircleOutlined,
|
||||
BookOutlined,
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
EyeOutlined,
|
||||
TagOutlined,
|
||||
ShareAltOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { knowledgeApi, type KnowledgeArticleDetail } from '@/api/knowledge'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const detail = ref<KnowledgeArticleDetail | null>(null)
|
||||
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const id = Number(route.params.id)
|
||||
if (!id) {
|
||||
message.error('参数错误')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
const res = await knowledgeApi.getArticle(id)
|
||||
const d = res.data
|
||||
if (d.status !== 'published') {
|
||||
message.warning('该文章未发布,无法查看')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
detail.value = d
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const goBack = () => router.push({ path: '/dashboard', query: { menu: 'knowledge-center' } })
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
message.success('链接已复制到剪贴板')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制链接')
|
||||
}
|
||||
}
|
||||
const printPage = () => window.print()
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables';
|
||||
|
||||
.knowledge-detail {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
|
||||
// 科技风头部导航
|
||||
.tech-header {
|
||||
position: relative;
|
||||
padding: 24px 0;
|
||||
margin-bottom: 32px;
|
||||
overflow: hidden;
|
||||
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
|
||||
|
||||
.tech-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(37, 99, 235, 0.1) 0%, transparent 50%);
|
||||
animation: patternFloat 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gradient-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
|
||||
.nav-btn {
|
||||
border-radius: 12px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&.back-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.copy-btn {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&.print-btn {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章容器
|
||||
.article-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
|
||||
.article-wrapper {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// 文章头部
|
||||
.article-header {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
padding: 40px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.article-status {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.status-badge {
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
margin: 0 0 32px 0;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meta-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-tags {
|
||||
.tags-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.tech-tag {
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%);
|
||||
color: #0277bd;
|
||||
border: 1px solid #81d4fa;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(2, 119, 189, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章内容
|
||||
.article-content {
|
||||
padding: 40px;
|
||||
|
||||
.content-wrapper {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: #374151;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
|
||||
// 内容样式优化
|
||||
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
margin: 32px 0 16px 0;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(h1) { font-size: 2rem; }
|
||||
:deep(h2) { font-size: 1.75rem; }
|
||||
:deep(h3) { font-size: 1.5rem; }
|
||||
:deep(h4) { font-size: 1.25rem; }
|
||||
|
||||
:deep(p) {
|
||||
margin: 16px 0;
|
||||
text-align: justify;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
padding: 16px 24px;
|
||||
margin: 24px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
color: #64748b;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: #e11d48;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 24px 0;
|
||||
|
||||
code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
table-layout: fixed;
|
||||
|
||||
th, td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章底部
|
||||
.article-footer {
|
||||
background: #f8fafc;
|
||||
padding: 32px 40px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 12px;
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 科技风动画
|
||||
@keyframes patternFloat {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
33% { transform: translate(30px, -30px) rotate(120deg); }
|
||||
66% { transform: translate(-20px, 20px) rotate(240deg); }
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.knowledge-detail {
|
||||
.tech-header {
|
||||
padding: 16px 0;
|
||||
|
||||
.header-content .nav-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.nav-btn {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-container {
|
||||
padding: 0 16px;
|
||||
|
||||
.article-wrapper {
|
||||
.article-header {
|
||||
padding: 24px;
|
||||
|
||||
.article-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.article-meta .meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
|
||||
.meta-item {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
padding: 24px;
|
||||
|
||||
.footer-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.action-btn {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2554
hertz_server_django_ui/src/views/user_pages/DetectionHistory.vue
Normal file
2554
hertz_server_django_ui/src/views/user_pages/DetectionHistory.vue
Normal file
File diff suppressed because it is too large
Load Diff
251
hertz_server_django_ui/src/views/user_pages/Documents.vue
Normal file
251
hertz_server_django_ui/src/views/user_pages/Documents.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="documents-page">
|
||||
<a-card title="文档管理" class="documents-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索文档"
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" @click="showUploadModal = true">
|
||||
<UploadOutlined />
|
||||
上传文档
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredDocuments"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a-space>
|
||||
<component :is="getFileIcon(record.type)" />
|
||||
<a @click="previewDocument(record)">{{ record.name }}</a>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'size'">
|
||||
{{ formatFileSize(record.size) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="downloadDocument(record)">下载</a-button>
|
||||
<a-button type="link" size="small" @click="shareDocument(record)">分享</a-button>
|
||||
<a-button type="link" size="small" danger @click="deleteDocument(record.id)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 上传文档模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showUploadModal"
|
||||
title="上传文档"
|
||||
@ok="handleUpload"
|
||||
@cancel="resetUploadForm"
|
||||
>
|
||||
<a-form :model="uploadForm" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="文档名称" name="name">
|
||||
<a-input v-model:value="uploadForm.name" placeholder="可选,默认使用文件名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文档描述" name="description">
|
||||
<a-textarea v-model:value="uploadForm.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="选择文件" name="file" :rules="[{ required: true, message: '请选择文件' }]">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:remove="handleRemove"
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
>
|
||||
<a-button>
|
||||
<UploadOutlined />
|
||||
选择文件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileTextOutlined, FilePdfOutlined, FileWordOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Document {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
size: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '文档名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '大小', dataIndex: 'size', key: 'size', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 200 }
|
||||
]
|
||||
|
||||
const documents = ref<Document[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '项目需求文档.pdf',
|
||||
description: '项目的详细需求说明',
|
||||
type: 'pdf',
|
||||
size: 2048576,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '技术方案.docx',
|
||||
description: '技术实现方案文档',
|
||||
type: 'docx',
|
||||
size: 1024000,
|
||||
created_at: '2024-01-02',
|
||||
updated_at: '2024-01-02'
|
||||
}
|
||||
])
|
||||
|
||||
const searchText = ref('')
|
||||
const showUploadModal = ref(false)
|
||||
const fileList = ref([])
|
||||
const uploadForm = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return documents.value
|
||||
}
|
||||
return documents.value.filter(doc =>
|
||||
doc.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||
doc.description.toLowerCase().includes(searchText.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
const iconMap: Record<string, any> = {
|
||||
pdf: FilePdfOutlined,
|
||||
doc: FileWordOutlined,
|
||||
docx: FileWordOutlined,
|
||||
txt: FileTextOutlined,
|
||||
md: FileTextOutlined
|
||||
}
|
||||
return iconMap[type] || FileTextOutlined
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在 computed 中实现
|
||||
}
|
||||
|
||||
const previewDocument = (doc: Document) => {
|
||||
message.info(`预览文档: ${doc.name}`)
|
||||
// 这里可以实现文档预览功能
|
||||
}
|
||||
|
||||
const downloadDocument = (doc: Document) => {
|
||||
message.success(`开始下载: ${doc.name}`)
|
||||
// 这里可以实现文档下载功能
|
||||
}
|
||||
|
||||
const shareDocument = (doc: Document) => {
|
||||
message.info(`分享文档: ${doc.name}`)
|
||||
// 这里可以实现文档分享功能
|
||||
}
|
||||
|
||||
const deleteDocument = (id: number) => {
|
||||
const index = documents.value.findIndex(doc => doc.id === id)
|
||||
if (index > -1) {
|
||||
documents.value.splice(index, 1)
|
||||
message.success('文档删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload = (file: any) => {
|
||||
const isValidType = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', 'text/markdown'].includes(file.type)
|
||||
if (!isValidType) {
|
||||
message.error('只能上传 PDF、Word、TXT、MD 格式的文件!')
|
||||
return false
|
||||
}
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
message.error('文件大小不能超过 10MB!')
|
||||
return false
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
if (fileList.value.length === 0) {
|
||||
message.error('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const file = fileList.value[0] as any
|
||||
const newDocument: Document = {
|
||||
id: Date.now(),
|
||||
name: uploadForm.value.name || file.name,
|
||||
description: uploadForm.value.description,
|
||||
type: file.name.split('.').pop() || 'unknown',
|
||||
size: file.size,
|
||||
created_at: new Date().toISOString().split('T')[0],
|
||||
updated_at: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
documents.value.unshift(newDocument)
|
||||
message.success('文档上传成功')
|
||||
resetUploadForm()
|
||||
}
|
||||
|
||||
const resetUploadForm = () => {
|
||||
showUploadModal.value = false
|
||||
fileList.value = []
|
||||
uploadForm.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 这里可以调用获取文档列表的API
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.documents-page {
|
||||
padding: 24px;
|
||||
|
||||
.documents-card {
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2499
hertz_server_django_ui/src/views/user_pages/KbCenter.vue
Normal file
2499
hertz_server_django_ui/src/views/user_pages/KbCenter.vue
Normal file
File diff suppressed because it is too large
Load Diff
2185
hertz_server_django_ui/src/views/user_pages/LiveDetection.vue
Normal file
2185
hertz_server_django_ui/src/views/user_pages/LiveDetection.vue
Normal file
File diff suppressed because it is too large
Load Diff
318
hertz_server_django_ui/src/views/user_pages/Messages.vue
Normal file
318
hertz_server_django_ui/src/views/user_pages/Messages.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="messages-page">
|
||||
<a-card title="消息中心" class="messages-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="markAllAsRead" :disabled="unreadCount === 0">
|
||||
全部标记为已读
|
||||
</a-button>
|
||||
<a-badge :count="unreadCount">
|
||||
<BellOutlined style="font-size: 16px;" />
|
||||
</a-badge>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<a-tab-pane key="all" tab="全部消息">
|
||||
<a-list
|
||||
:data-source="filteredMessages"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
item-layout="horizontal"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item
|
||||
:class="{ 'unread-message': !item.read }"
|
||||
@click="markAsRead(item)"
|
||||
>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<a-avatar :style="{ backgroundColor: getTypeColor(item.type) }">
|
||||
<component :is="getTypeIcon(item.type)" />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="message-title">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-tag v-if="!item.read" color="red" size="small">未读</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="message-content">
|
||||
<p>{{ item.content }}</p>
|
||||
<span class="message-time">{{ formatTime(item.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="unread" tab="未读消息">
|
||||
<a-list
|
||||
:data-source="unreadMessages"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
item-layout="horizontal"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item @click="markAsRead(item)">
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<a-avatar :style="{ backgroundColor: getTypeColor(item.type) }">
|
||||
<component :is="getTypeIcon(item.type)" />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="message-title">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-tag color="red" size="small">未读</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="message-content">
|
||||
<p>{{ item.content }}</p>
|
||||
<span class="message-time">{{ formatTime(item.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="system" tab="系统通知">
|
||||
<a-list
|
||||
:data-source="systemMessages"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
item-layout="horizontal"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item
|
||||
:class="{ 'unread-message': !item.read }"
|
||||
@click="markAsRead(item)"
|
||||
>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<a-avatar style="background-color: #1890ff">
|
||||
<NotificationOutlined />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="message-title">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-tag v-if="!item.read" color="red" size="small">未读</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="message-content">
|
||||
<p>{{ item.content }}</p>
|
||||
<span class="message-time">{{ formatTime(item.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button type="link" size="small" @click.stop="deleteMessage(item.id)">
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
BellOutlined,
|
||||
NotificationOutlined,
|
||||
UserOutlined,
|
||||
WarningOutlined,
|
||||
CheckCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
interface Message {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
type: 'system' | 'user' | 'warning' | 'success'
|
||||
read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const activeTab = ref('all')
|
||||
|
||||
const messages = ref<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '系统维护通知',
|
||||
content: '系统将于今晚22:00-24:00进行维护,期间可能无法正常访问,请提前做好准备。',
|
||||
type: 'system',
|
||||
read: false,
|
||||
created_at: '2024-01-15T10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '密码即将过期',
|
||||
content: '您的密码将在7天后过期,请及时修改密码以确保账户安全。',
|
||||
type: 'warning',
|
||||
read: false,
|
||||
created_at: '2024-01-14T15:20:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '项目审核通过',
|
||||
content: '恭喜!您提交的项目"用户管理系统"已通过审核,可以开始正式开发。',
|
||||
type: 'success',
|
||||
read: true,
|
||||
created_at: '2024-01-13T09:15:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '新用户注册',
|
||||
content: '有新用户注册了您的应用,请及时查看用户信息。',
|
||||
type: 'user',
|
||||
read: false,
|
||||
created_at: '2024-01-12T14:45:00'
|
||||
}
|
||||
])
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'unread':
|
||||
return messages.value.filter(msg => !msg.read)
|
||||
case 'system':
|
||||
return messages.value.filter(msg => msg.type === 'system')
|
||||
default:
|
||||
return messages.value
|
||||
}
|
||||
})
|
||||
|
||||
const unreadMessages = computed(() => {
|
||||
return messages.value.filter(msg => !msg.read)
|
||||
})
|
||||
|
||||
const systemMessages = computed(() => {
|
||||
return messages.value.filter(msg => msg.type === 'system')
|
||||
})
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
return messages.value.filter(msg => !msg.read).length
|
||||
})
|
||||
|
||||
const getTypeColor = (type: Message['type']) => {
|
||||
const colors = {
|
||||
system: '#1890ff',
|
||||
user: '#52c41a',
|
||||
warning: '#faad14',
|
||||
success: '#52c41a'
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: Message['type']) => {
|
||||
const icons = {
|
||||
system: NotificationOutlined,
|
||||
user: UserOutlined,
|
||||
warning: WarningOutlined,
|
||||
success: CheckCircleOutlined
|
||||
}
|
||||
return icons[type]
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const markAsRead = (msg: Message) => {
|
||||
if (!msg.read) {
|
||||
msg.read = true
|
||||
message.success('消息已标记为已读')
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const unreadMessages = messages.value.filter(msg => !msg.read)
|
||||
unreadMessages.forEach(msg => {
|
||||
msg.read = true
|
||||
})
|
||||
if (unreadMessages.length > 0) {
|
||||
message.success(`已将 ${unreadMessages.length} 条消息标记为已读`)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMessage = (id: number) => {
|
||||
const index = messages.value.findIndex(msg => msg.id === id)
|
||||
if (index > -1) {
|
||||
messages.value.splice(index, 1)
|
||||
message.success('消息删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
activeTab.value = key
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 这里可以调用获取消息列表的API
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.messages-page {
|
||||
padding: 24px;
|
||||
|
||||
.messages-card {
|
||||
.unread-message {
|
||||
background-color: #f6ffed;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-list-item) {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
842
hertz_server_django_ui/src/views/user_pages/NoticeCenter.vue
Normal file
842
hertz_server_django_ui/src/views/user_pages/NoticeCenter.vue
Normal file
@@ -0,0 +1,842 @@
|
||||
<template>
|
||||
<div class="notice-center">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
<BellOutlined class="title-icon" />
|
||||
通知中心
|
||||
</h1>
|
||||
<p class="page-description">管理您的通知和消息</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="unreadIds.length === 0"
|
||||
@click="markAllRead"
|
||||
:loading="batchLoading"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckCircleOutlined />
|
||||
</template>
|
||||
全部标记为已读
|
||||
</a-button>
|
||||
<a-button
|
||||
@click="refreshList"
|
||||
:loading="loadingList"
|
||||
>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-container">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon total">
|
||||
<BellOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.total_count }}</div>
|
||||
<div class="stat-label">总消息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon unread">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.unread_count }}</div>
|
||||
<div class="stat-label">未读消息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon read">
|
||||
<CheckCircleOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.read_count }}</div>
|
||||
<div class="stat-label">已读消息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon starred">
|
||||
<StarOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.starred_count }}</div>
|
||||
<div class="stat-label">收藏消息</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="main-container">
|
||||
<div class="content-wrapper">
|
||||
<!-- 标签页 -->
|
||||
<div class="tabs-container">
|
||||
<a-tabs v-model:activeKey="activeTab" @change="onTabChange" class="tech-tabs">
|
||||
<a-tab-pane key="all" tab="全部消息" />
|
||||
<a-tab-pane key="unread" tab="未读消息" />
|
||||
<a-tab-pane key="starred" tab="收藏消息" />
|
||||
<a-tab-pane key="system" tab="系统通知" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!loadingList && notices.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<BellOutlined />
|
||||
</div>
|
||||
<h3 class="empty-title">暂无可见通知</h3>
|
||||
<p class="empty-description">{{ emptyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div v-else class="notices-container">
|
||||
<a-list :data-source="displayedNotices" :loading="loadingList" item-layout="vertical" class="tech-list">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item class="notice-item">
|
||||
<div class="notice-card" :class="{ 'unread': !item.is_read, 'starred': item.is_starred }">
|
||||
<div class="notice-header">
|
||||
<div class="notice-icon" :class="iconClass(item.notice_type_display)">
|
||||
<component :is="getIconComponent(item.notice_type_display)" />
|
||||
</div>
|
||||
<div class="notice-content">
|
||||
<div class="title-line">
|
||||
<h3 class="notice-title">{{ item.title }}</h3>
|
||||
<div class="notice-badges">
|
||||
<a-tag v-if="item.is_top" color="red" class="top-tag">置顶</a-tag>
|
||||
<a-tag :color="item.is_read ? 'green' : 'orange'" class="status-tag">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<p class="notice-desc">{{ itemDesc(item) }}</p>
|
||||
<div class="notice-meta">
|
||||
<span class="publish-time">
|
||||
<ClockCircleOutlined />
|
||||
{{ item.publish_time }}
|
||||
</span>
|
||||
<div class="notice-actions">
|
||||
<a-button type="link" @click="viewDetail(item)" class="action-link">
|
||||
<EyeOutlined />
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
:disabled="item.is_read"
|
||||
@click="markRead(item)"
|
||||
class="action-link"
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
标记已读
|
||||
</a-button>
|
||||
<a-button type="link" @click="toggleStar(item)" class="action-link">
|
||||
<StarOutlined />
|
||||
{{ item.is_starred ? '取消收藏' : '收藏' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<a-pagination
|
||||
:current="currentPage"
|
||||
:pageSize="pageSize"
|
||||
:total="totalCount"
|
||||
show-size-changer
|
||||
@change="handlePageChange"
|
||||
@showSizeChange="handlePageSizeChange"
|
||||
class="tech-pagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情模态框 -->
|
||||
<a-modal
|
||||
v-model:visible="detailVisible"
|
||||
title="通知详情"
|
||||
width="720px"
|
||||
@cancel="closeDetail"
|
||||
:footer="null"
|
||||
class="detail-modal"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<div class="detail-content">
|
||||
<a-descriptions :column="1" bordered class="tech-descriptions">
|
||||
<a-descriptions-item label="标题">
|
||||
<span class="detail-title">{{ detailData?.title }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="内容">
|
||||
<div class="detail-text">{{ detailData?.content }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
<a-tag :color="getTypeColor(detailData?.notice_type_display)">
|
||||
{{ detailData?.notice_type_display }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="优先级">
|
||||
<a-tag :color="getPriorityColor(detailData?.priority_display)">
|
||||
{{ detailData?.priority_display }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发布时间">
|
||||
<span class="detail-time">{{ detailData?.publish_time }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="过期时间">
|
||||
<span class="detail-time">{{ detailData?.expire_time || '永久有效' }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态信息">
|
||||
<div class="status-info">
|
||||
<a-tag :color="detailData?.is_top ? 'red' : 'default'">
|
||||
{{ detailData?.is_top ? '置顶' : '普通' }}
|
||||
</a-tag>
|
||||
<a-tag :color="detailData?.is_starred ? 'gold' : 'default'">
|
||||
{{ detailData?.is_starred ? '已收藏' : '未收藏' }}
|
||||
</a-tag>
|
||||
<a-tag :color="detailData?.is_read ? 'green' : 'orange'">
|
||||
{{ detailData?.is_read ? '已读' : '未读' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
BellOutlined,
|
||||
CheckCircleOutlined,
|
||||
ReloadOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
StarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EyeOutlined,
|
||||
SettingOutlined,
|
||||
WarningOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { noticeUserApi, type UserNoticeListItem, type UserNoticeListData, type UserNoticeDetailData } from '@/api/notice_user'
|
||||
|
||||
const loadingList = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const notices = ref<UserNoticeListItem[]>([])
|
||||
const totalCount = ref(0)
|
||||
const batchLoading = ref(false)
|
||||
const activeTab = ref<'all' | 'unread' | 'starred' | 'system'>('all')
|
||||
|
||||
const statistics = reactive({
|
||||
total_count: 0,
|
||||
unread_count: 0,
|
||||
read_count: 0,
|
||||
starred_count: 0,
|
||||
})
|
||||
|
||||
const unreadIds = computed(() => notices.value.filter(n => !n.is_read).map(n => n.notice))
|
||||
const displayedNotices = computed(() => {
|
||||
let arr = notices.value
|
||||
if (activeTab.value === 'unread') {
|
||||
arr = arr.filter(n => !n.is_read)
|
||||
} else if (activeTab.value === 'starred') {
|
||||
arr = arr.filter(n => n.is_starred)
|
||||
} else if (activeTab.value === 'system') {
|
||||
arr = arr.filter(n => n.notice_type_display === '系统通知')
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
// 空状态提示文案
|
||||
const emptyHint = computed(() => {
|
||||
if (activeTab.value === 'starred') {
|
||||
return '您还没有收藏任何消息。点击消息卡片中的“收藏”按钮可以收藏消息。'
|
||||
} else if (activeTab.value === 'unread') {
|
||||
return '您没有未读消息。所有消息都已阅读完毕。'
|
||||
} else if (activeTab.value === 'system') {
|
||||
return '暂无系统通知。'
|
||||
}
|
||||
return '可能原因:通知未发布或已过期。请联系管理员在“通知管理”中点击“发布”,或调整过期时间后再试。'
|
||||
})
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const res = await noticeUserApi.statistics()
|
||||
if (res.success) {
|
||||
const s = res.data
|
||||
statistics.total_count = s.total_count || 0
|
||||
statistics.unread_count = s.unread_count || 0
|
||||
statistics.starred_count = s.starred_count || 0
|
||||
statistics.read_count = s.read_count || (s.total_count - s.unread_count)
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默统计失败
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const res = await noticeUserApi.list({ page: currentPage.value, page_size: pageSize.value })
|
||||
if (res.success) {
|
||||
notices.value = res.data.notices || []
|
||||
totalCount.value = res.data.pagination?.total_count || 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取通知列表失败')
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshList = () => {
|
||||
fetchList()
|
||||
fetchStatistics()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchList()
|
||||
}
|
||||
const handlePageSizeChange = (_: number, size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchList()
|
||||
}
|
||||
const onTabChange = () => {
|
||||
// 标签切换后回到第一页,并刷新当前页数据与统计
|
||||
currentPage.value = 1
|
||||
fetchList()
|
||||
fetchStatistics()
|
||||
}
|
||||
|
||||
// 左侧图标样式映射
|
||||
const iconClass = (type: string) => {
|
||||
switch (type) {
|
||||
case '系统通知':
|
||||
return 'icon-system'
|
||||
case '安全提醒':
|
||||
case '告警':
|
||||
case '风险提示':
|
||||
return 'icon-warning'
|
||||
case '业务通知':
|
||||
case '普通通知':
|
||||
case '成功':
|
||||
return 'icon-success'
|
||||
default:
|
||||
return 'icon-default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (type: string) => {
|
||||
switch (type) {
|
||||
case '系统通知':
|
||||
return SettingOutlined
|
||||
case '安全提醒':
|
||||
case '告警':
|
||||
case '风险提示':
|
||||
return WarningOutlined
|
||||
case '业务通知':
|
||||
case '普通通知':
|
||||
case '成功':
|
||||
return CheckCircleOutlined
|
||||
default:
|
||||
return InfoCircleOutlined
|
||||
}
|
||||
}
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type?: string) => {
|
||||
switch (type) {
|
||||
case '系统通知':
|
||||
return 'blue'
|
||||
case '安全提醒':
|
||||
case '告警':
|
||||
case '风险提示':
|
||||
return 'red'
|
||||
case '业务通知':
|
||||
case '普通通知':
|
||||
case '成功':
|
||||
return 'green'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优先级颜色
|
||||
const getPriorityColor = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case '高':
|
||||
return 'red'
|
||||
case '中':
|
||||
return 'orange'
|
||||
case '低':
|
||||
return 'green'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 列表摘要描述(依据文档字段,列表不含正文,展示类型与优先级)
|
||||
const itemDesc = (item: UserNoticeListItem) => {
|
||||
const parts: string[] = []
|
||||
if (item.notice_type_display) parts.push(item.notice_type_display)
|
||||
if (item.priority_display) parts.push(item.priority_display)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<UserNoticeDetailData | null>(null)
|
||||
|
||||
const viewDetail = async (record: UserNoticeListItem) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await noticeUserApi.detail(record.notice)
|
||||
if (res.success) {
|
||||
detailData.value = res.data
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取通知详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
detailData.value = null
|
||||
}
|
||||
|
||||
const markRead = async (record: UserNoticeListItem) => {
|
||||
try {
|
||||
const res = await noticeUserApi.markRead(record.notice)
|
||||
if (res.success) {
|
||||
message.success('已标记为已读')
|
||||
refreshList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '标记已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
const ids = displayedNotices.value.filter(n => !n.is_read).map(n => n.notice)
|
||||
if (ids.length === 0) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res = await noticeUserApi.batchMarkRead(ids)
|
||||
if (res.success) {
|
||||
message.success(`已标记 ${res.data?.updated_count || ids.length} 条为已读`)
|
||||
refreshList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量标记失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStar = async (record: UserNoticeListItem) => {
|
||||
try {
|
||||
const res = await noticeUserApi.toggleStar(record.notice, !record.is_starred)
|
||||
if (res.success) {
|
||||
message.success(record.is_starred ? '已取消收藏' : '已收藏')
|
||||
fetchList()
|
||||
fetchStatistics()
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables';
|
||||
|
||||
.notice-center {
|
||||
padding: 24px;
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
min-height: 100vh;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.title-icon {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作栏
|
||||
.action-bar {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stats-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 24px;
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.stat-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
|
||||
&.total {
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
&.read {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
&.starred {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--theme-text-secondary, #6b7280);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主要内容区域
|
||||
.main-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
.content-wrapper {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
|
||||
.tabs-container {
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
|
||||
.tech-tabs {
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
color: #64748b;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #374151);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.notices-container {
|
||||
padding: 24px;
|
||||
|
||||
.tech-list {
|
||||
:deep(.ant-list-item) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
.notice-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
&.unread {
|
||||
border-left: 4px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
&.starred {
|
||||
border-left: 4px solid #8b5cf6;
|
||||
background: #faf5ff;
|
||||
}
|
||||
|
||||
.notice-header {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.notice-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.icon-system {
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
}
|
||||
|
||||
&.icon-warning {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
&.icon-success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
&.icon-default {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
flex: 1;
|
||||
|
||||
.title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.notice-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notice-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.top-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-desc {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notice-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.publish-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notice-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.action-link {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 详情模态框
|
||||
.detail-modal {
|
||||
:deep(.ant-modal-content) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
.tech-descriptions {
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #374151);
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-content) {
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-text-primary, #374151);
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
519
hertz_server_django_ui/src/views/user_pages/Profile.vue
Normal file
519
hertz_server_django_ui/src/views/user_pages/Profile.vue
Normal file
@@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
<UserOutlined class="title-icon" />
|
||||
个人资料
|
||||
</h1>
|
||||
<p class="page-description">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="user-info-section">
|
||||
<div class="user-info-card">
|
||||
<div class="user-avatar-section">
|
||||
<a-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>
|
||||
</div>
|
||||
|
||||
<!-- 资料表单区域 -->
|
||||
<div class="profile-container">
|
||||
<div class="profile-wrapper">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<div class="panel-icon">
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
<div class="title-group">
|
||||
<h3 class="panel-title">基本信息</h3>
|
||||
<span class="panel-subtitle">更新您的个人资料信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<a-form
|
||||
:model="userForm"
|
||||
layout="vertical"
|
||||
@finish="handleSubmit"
|
||||
class="profile-form"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">
|
||||
<UserOutlined />
|
||||
账户信息
|
||||
</h4>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户名" name="username" class="form-item">
|
||||
<a-input
|
||||
v-model:value="userForm.username"
|
||||
disabled
|
||||
class="form-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="邮箱地址" name="email" class="form-item">
|
||||
<a-input
|
||||
v-model:value="userForm.email"
|
||||
class="form-input"
|
||||
placeholder="请输入邮箱地址"
|
||||
>
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">
|
||||
<IdcardOutlined />
|
||||
个人信息
|
||||
</h4>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="真实姓名" name="real_name" class="form-item">
|
||||
<a-input
|
||||
v-model:value="userForm.real_name"
|
||||
class="form-input"
|
||||
placeholder="请输入真实姓名"
|
||||
>
|
||||
<template #prefix>
|
||||
<IdcardOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="手机号码" name="phone" class="form-item">
|
||||
<a-input
|
||||
v-model:value="userForm.phone"
|
||||
class="form-input"
|
||||
placeholder="请输入手机号码"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">
|
||||
<CalendarOutlined />
|
||||
其他信息
|
||||
</h4>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="性别" name="gender" class="form-item">
|
||||
<a-select
|
||||
v-model:value="userForm.gender"
|
||||
class="form-select"
|
||||
placeholder="请选择性别"
|
||||
>
|
||||
<a-select-option :value="0">未知</a-select-option>
|
||||
<a-select-option :value="1">男</a-select-option>
|
||||
<a-select-option :value="2">女</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="生日" name="birthday" class="form-item">
|
||||
<a-date-picker
|
||||
v-model:value="userForm.birthday"
|
||||
class="form-date-picker"
|
||||
placeholder="请选择生日"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
:loading="loading"
|
||||
class="submit-btn"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<SaveOutlined />
|
||||
</template>
|
||||
保存更改
|
||||
</a-button>
|
||||
<a-button
|
||||
@click="resetForm"
|
||||
class="reset-btn"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
重置表单
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
IdcardOutlined,
|
||||
CalendarOutlined,
|
||||
SaveOutlined,
|
||||
ReloadOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { userApi, type User } from '@/api/user'
|
||||
import { 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: '',
|
||||
})
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const response = await userApi.getUserInfo()
|
||||
if (response.success) {
|
||||
userForm.value = {
|
||||
...response.data,
|
||||
birthday: response.data.birthday ? dayjs(response.data.birthday) : undefined,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
message.error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const submitData: any = {
|
||||
...userForm.value,
|
||||
birthday: userForm.value.birthday
|
||||
? dayjs(userForm.value.birthday as any).format('YYYY-MM-DD')
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// 头像通过单独的上传接口处理,这里不再提交 avatar 字段,避免 URL 校验报错
|
||||
if ('avatar' in submitData) {
|
||||
delete submitData.avatar
|
||||
}
|
||||
|
||||
const response = await userApi.updateUserInfo(submitData)
|
||||
if (response.success) {
|
||||
message.success('个人信息更新成功!')
|
||||
await fetchUserInfo()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新失败:', error)
|
||||
message.error('更新失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = async () => {
|
||||
try {
|
||||
await fetchUserInfo()
|
||||
message.success('表单已重置为最新数据')
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error)
|
||||
message.error('重置失败,请刷新页面')
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
.profile-page {
|
||||
padding: 24px;
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
min-height: 100vh;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.title-icon {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 24px;
|
||||
|
||||
.user-info-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
|
||||
.user-avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
.avatar {
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border: 3px solid var(--theme-card-border, #e5e7eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-info {
|
||||
.username {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary, #94a3b8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
.profile-wrapper {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.panel-header {
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.panel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
.panel-title {
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
|
||||
.profile-form {
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
|
||||
.form-section {
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0 0 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
|
||||
.submit-btn,
|
||||
.reset-btn {
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
835
hertz_server_django_ui/src/views/user_pages/SystemMonitor.vue
Normal file
835
hertz_server_django_ui/src/views/user_pages/SystemMonitor.vue
Normal file
@@ -0,0 +1,835 @@
|
||||
<template>
|
||||
<div class="system-monitor">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
<DatabaseOutlined class="title-icon" />
|
||||
系统监控
|
||||
</h1>
|
||||
<p class="page-description">实时监控系统运行状态和性能指标</p>
|
||||
</div>
|
||||
|
||||
<div class="monitor-content">
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 刷新按钮 -->
|
||||
<div class="refresh-actions">
|
||||
<a-button
|
||||
:loading="loading"
|
||||
@click="fetchAll"
|
||||
type="primary"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 系统概览卡片 -->
|
||||
<div class="overview-cards" :class="currentLayoutConfig.overviewClass">
|
||||
<a-row :gutter="currentLayoutConfig.overviewGutter">
|
||||
<!-- 系统信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.overviewLayout.xs"
|
||||
:sm="currentLayoutConfig.overviewLayout.sm"
|
||||
:md="currentLayoutConfig.overviewLayout.md"
|
||||
:lg="currentLayoutConfig.overviewLayout.lg"
|
||||
:xl="currentLayoutConfig.overviewLayout.xl"
|
||||
>
|
||||
<div class="monitor-card system-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<DesktopOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">系统信息</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">主机名</span>
|
||||
<span class="info-value">{{ system?.hostname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">平台</span>
|
||||
<span class="info-value">{{ system?.platform || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">架构</span>
|
||||
<span class="info-value">{{ system?.architecture || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">启动时间</span>
|
||||
<span class="info-value">{{ system?.boot_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">运行时长</span>
|
||||
<span class="info-value">{{ system?.uptime || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- CPU信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.overviewLayout.xs"
|
||||
:sm="currentLayoutConfig.overviewLayout.sm"
|
||||
:md="currentLayoutConfig.overviewLayout.md"
|
||||
:lg="currentLayoutConfig.overviewLayout.lg"
|
||||
:xl="currentLayoutConfig.overviewLayout.xl"
|
||||
>
|
||||
<div class="monitor-card cpu-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<DesktopOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">CPU 信息</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="cpu-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ toNum(cpu?.cpu_count) ?? '-' }}</div>
|
||||
<div class="stat-label">核心数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value cpu-usage">{{ toNum(cpu?.cpu_percent) ?? '-' }}%</div>
|
||||
<div class="stat-label">使用率</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cpu-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">当前频率</span>
|
||||
<span class="detail-value">{{ toNum(cpu?.cpu_freq?.current) ?? '-' }} MHz</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">频率范围</span>
|
||||
<span class="detail-value">{{ toNum(cpu?.cpu_freq?.min) ?? '-' }} - {{ toNum(cpu?.cpu_freq?.max) ?? '-' }} MHz</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">负载均值</span>
|
||||
<span class="detail-value">{{ Array.isArray(cpu?.load_avg) ? cpu?.load_avg?.join(', ') : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 内存信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.overviewLayout.xs"
|
||||
:sm="currentLayoutConfig.overviewLayout.sm"
|
||||
:md="currentLayoutConfig.overviewLayout.md"
|
||||
:lg="currentLayoutConfig.overviewLayout.lg"
|
||||
:xl="currentLayoutConfig.overviewLayout.xl"
|
||||
>
|
||||
<div class="monitor-card memory-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<DatabaseOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">内存使用</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="memory-progress">
|
||||
<a-progress
|
||||
:percent="toNum(memory?.percent) ?? 0"
|
||||
:stroke-color="getMemoryColor(toNum(memory?.percent) ?? 0)"
|
||||
:show-info="false"
|
||||
stroke-width="8"
|
||||
/>
|
||||
<div class="progress-text">{{ toNum(memory?.percent) ?? 0 }}%</div>
|
||||
</div>
|
||||
<div class="memory-details">
|
||||
<div class="memory-item">
|
||||
<span class="memory-label">总量</span>
|
||||
<span class="memory-value">{{ formatBytesMaybe(memory?.total) }}</span>
|
||||
</div>
|
||||
<div class="memory-item">
|
||||
<span class="memory-label">已用</span>
|
||||
<span class="memory-value">{{ formatBytesMaybe(memory?.used) }}</span>
|
||||
</div>
|
||||
<div class="memory-item">
|
||||
<span class="memory-label">可用</span>
|
||||
<span class="memory-value">{{ formatBytesMaybe(memory?.available) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 详细监控数据 -->
|
||||
<div class="detail-sections" :class="currentLayoutConfig.detailClass">
|
||||
<a-row :gutter="currentLayoutConfig.detailGutter">
|
||||
<!-- GPU信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.detailLayout.xs"
|
||||
:sm="currentLayoutConfig.detailLayout.sm"
|
||||
:md="currentLayoutConfig.detailLayout.md"
|
||||
:lg="currentLayoutConfig.detailLayout.lg"
|
||||
:xl="currentLayoutConfig.detailLayout.xl"
|
||||
>
|
||||
<div class="monitor-card gpu-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<DatabaseOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">GPU 信息</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div v-if="gpu?.gpu_available" class="gpu-table">
|
||||
<a-table
|
||||
:data-source="gpu?.gpu_info || []"
|
||||
:columns="gpuColumns"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="gpu-unavailable">
|
||||
<a-alert
|
||||
:message="gpu?.message || '未检测到GPU设备'"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
<div class="gpu-timestamp">更新时间:{{ gpu?.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 磁盘信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.detailLayout.xs"
|
||||
:sm="currentLayoutConfig.detailLayout.sm"
|
||||
:md="currentLayoutConfig.detailLayout.md"
|
||||
:lg="currentLayoutConfig.detailLayout.lg"
|
||||
:xl="currentLayoutConfig.detailLayout.xl"
|
||||
>
|
||||
<div class="monitor-card disk-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<DatabaseOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">磁盘使用</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<a-table
|
||||
:data-source="disks"
|
||||
:columns="diskColumns"
|
||||
row-key="device"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="currentLayoutConfig.detailGutter" style="margin-top: 24px;">
|
||||
<!-- 网络信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.detailLayout.xs"
|
||||
:sm="currentLayoutConfig.detailLayout.sm"
|
||||
:md="currentLayoutConfig.detailLayout.md"
|
||||
:lg="currentLayoutConfig.detailLayout.lg"
|
||||
:xl="currentLayoutConfig.detailLayout.xl"
|
||||
>
|
||||
<div class="monitor-card network-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<WifiOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">网络接口</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<a-table
|
||||
:data-source="network"
|
||||
:columns="networkColumns"
|
||||
row-key="interface"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 进程信息 -->
|
||||
<a-col
|
||||
:xs="currentLayoutConfig.detailLayout.xs"
|
||||
:sm="currentLayoutConfig.detailLayout.sm"
|
||||
:md="currentLayoutConfig.detailLayout.md"
|
||||
:lg="currentLayoutConfig.detailLayout.lg"
|
||||
:xl="currentLayoutConfig.detailLayout.xl"
|
||||
>
|
||||
<div class="monitor-card process-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<CodeOutlined />
|
||||
</div>
|
||||
<h3 class="card-title">进程监控 (Top 10)</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<a-table
|
||||
:data-source="processes"
|
||||
:columns="processColumns"
|
||||
row-key="pid"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DesktopOutlined,
|
||||
DatabaseOutlined,
|
||||
WifiOutlined,
|
||||
CodeOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { systemMonitorApi, type SystemInfo, type CpuInfo, type MemoryInfo, type DiskInfo, type NetworkInfo, type ProcessInfo, type GpuInfoResponse } from '@/api/system_monitor'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 系统监控布局配置
|
||||
const systemMonitorLayouts = [
|
||||
{
|
||||
name: 'default',
|
||||
label: '标准布局',
|
||||
description: '概览卡片3列,详细信息2列,标准展示',
|
||||
overviewClass: 'overview-3col',
|
||||
detailClass: 'detail-2col',
|
||||
overviewLayout: { xs: 24, md: 12, lg: 8 },
|
||||
detailLayout: { xs: 24, lg: 12 },
|
||||
overviewGutter: [16, 16],
|
||||
detailGutter: [16, 16]
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
label: '紧凑布局',
|
||||
description: '概览卡片2列,详细信息全宽,适合小屏',
|
||||
overviewClass: 'overview-2col',
|
||||
detailClass: 'detail-full',
|
||||
overviewLayout: { xs: 24, sm: 12, lg: 12 },
|
||||
detailLayout: { xs: 24, lg: 24 },
|
||||
overviewGutter: [12, 12],
|
||||
detailGutter: [12, 12]
|
||||
},
|
||||
{
|
||||
name: 'wide',
|
||||
label: '宽屏布局',
|
||||
description: '概览卡片4列,详细信息并排,充分利用宽屏',
|
||||
overviewClass: 'overview-4col',
|
||||
detailClass: 'detail-side',
|
||||
overviewLayout: { xs: 24, sm: 12, lg: 6, xl: 6 },
|
||||
detailLayout: { xs: 24, lg: 12 },
|
||||
overviewGutter: [20, 20],
|
||||
detailGutter: [20, 20]
|
||||
}
|
||||
]
|
||||
|
||||
// 当前布局
|
||||
const currentLayout = ref<string>('default')
|
||||
|
||||
// 计算当前布局配置
|
||||
const currentLayoutConfig = computed(() => {
|
||||
return systemMonitorLayouts.find(l => l.name === currentLayout.value) || systemMonitorLayouts[0]
|
||||
})
|
||||
|
||||
// 加载布局配置
|
||||
const loadLayout = () => {
|
||||
const savedLayout = localStorage.getItem('systemMonitorLayout')
|
||||
if (savedLayout && systemMonitorLayouts.find(l => l.name === savedLayout)) {
|
||||
currentLayout.value = savedLayout
|
||||
} else {
|
||||
currentLayout.value = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听布局变化事件
|
||||
const handleLayoutChange = () => {
|
||||
loadLayout()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLayout()
|
||||
window.addEventListener('systemMonitorLayoutChanged', handleLayoutChange)
|
||||
fetchAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('systemMonitorLayoutChanged', handleLayoutChange)
|
||||
})
|
||||
|
||||
const system = ref<SystemInfo | null>(null)
|
||||
const cpu = ref<CpuInfo | null>(null)
|
||||
const memory = ref<MemoryInfo | null>(null)
|
||||
const disks = ref<DiskInfo[]>([])
|
||||
const network = ref<NetworkInfo[]>([])
|
||||
const processes = ref<ProcessInfo[]>([])
|
||||
const gpu = ref<GpuInfoResponse | null>(null)
|
||||
|
||||
const fetchAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await systemMonitorApi.getMonitor()
|
||||
if (res.success) {
|
||||
system.value = res.data?.system ?? null
|
||||
cpu.value = res.data?.cpu ?? null
|
||||
memory.value = res.data?.memory ?? null
|
||||
disks.value = res.data?.disks ?? []
|
||||
network.value = res.data?.network ?? []
|
||||
processes.value = res.data?.processes ?? []
|
||||
|
||||
// 若综合接口缺少关键数据,回退单项接口
|
||||
const fallbacks: Promise<any>[] = []
|
||||
if (!cpu.value || typeof cpu.value.cpu_percent !== 'number') {
|
||||
fallbacks.push(systemMonitorApi.getCpu().then(r => { if (r.success) cpu.value = r.data }))
|
||||
}
|
||||
if (!memory.value || typeof memory.value.percent !== 'number') {
|
||||
fallbacks.push(systemMonitorApi.getMemory().then(r => { if (r.success) memory.value = r.data }))
|
||||
}
|
||||
if (!disks.value || disks.value.length === 0) {
|
||||
fallbacks.push(systemMonitorApi.getDisks().then(r => { if (r.success) disks.value = r.data }))
|
||||
}
|
||||
if (!network.value || network.value.length === 0) {
|
||||
fallbacks.push(systemMonitorApi.getNetwork().then(r => { if (r.success) network.value = r.data }))
|
||||
}
|
||||
if (!processes.value || processes.value.length === 0) {
|
||||
fallbacks.push(systemMonitorApi.getProcesses().then(r => { if (r.success) processes.value = r.data }))
|
||||
}
|
||||
|
||||
// GPU:始终尝试获取详细信息
|
||||
const gpuRes = await systemMonitorApi.getGpu()
|
||||
if (gpuRes.success) {
|
||||
gpu.value = gpuRes.data
|
||||
} else {
|
||||
const g = Array.isArray(res.data?.gpus) && res.data.gpus.length > 0 ? res.data.gpus[0] : undefined
|
||||
if (g) {
|
||||
gpu.value = { gpu_available: g.gpu_available, message: g.message, timestamp: g.timestamp, gpu_info: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbacks.length) await Promise.allSettled(fallbacks)
|
||||
} else {
|
||||
message.error(res.message || '获取监控数据失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '网络错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 表格列定义
|
||||
const diskColumns = [
|
||||
{ title: '设备', dataIndex: 'device', key: 'device' },
|
||||
{ title: '挂载点', dataIndex: 'mountpoint', key: 'mountpoint' },
|
||||
{ title: '类型', dataIndex: 'fstype', key: 'fstype' },
|
||||
{ title: '总容量', dataIndex: 'total', key: 'total', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '已用', dataIndex: 'used', key: 'used', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '空闲', dataIndex: 'free', key: 'free', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '使用率', dataIndex: 'percent', key: 'percent', customRender: ({ text }: any) => `${text}%` },
|
||||
]
|
||||
|
||||
const networkColumns = [
|
||||
{ title: '接口', dataIndex: 'interface', key: 'interface' },
|
||||
{ title: '发送', dataIndex: 'bytes_sent', key: 'bytes_sent', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '接收', dataIndex: 'bytes_recv', key: 'bytes_recv', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '发送包', dataIndex: 'packets_sent', key: 'packets_sent' },
|
||||
{ title: '接收包', dataIndex: 'packets_recv', key: 'packets_recv' },
|
||||
]
|
||||
|
||||
const processColumns = [
|
||||
{ title: 'PID', dataIndex: 'pid', key: 'pid' },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'CPU%', dataIndex: 'cpu_percent', key: 'cpu_percent' },
|
||||
{ title: '内存%', dataIndex: 'memory_percent', key: 'memory_percent' },
|
||||
{ title: 'RSS', dataIndex: ['memory_info','rss'], key: 'rss', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: 'VMS', dataIndex: ['memory_info','vms'], key: 'vms', customRender: ({ text }: any) => formatBytes(text) },
|
||||
{ title: '创建时间', dataIndex: 'create_time', key: 'create_time' },
|
||||
]
|
||||
|
||||
const gpuColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '负载%', dataIndex: 'load', key: 'load' },
|
||||
{ title: '总显存(MB)', dataIndex: 'memory_total', key: 'memory_total' },
|
||||
{ title: '已用(MB)', dataIndex: 'memory_used', key: 'memory_used' },
|
||||
{ title: '显存利用率%', dataIndex: 'memory_util', key: 'memory_util' },
|
||||
{ title: '温度(℃)', dataIndex: 'temperature', key: 'temperature' },
|
||||
]
|
||||
|
||||
function formatBytes(val?: number) {
|
||||
if (!val && val !== 0) return '-'
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(val) / Math.log(1024))
|
||||
const v = (val / Math.pow(1024, i))
|
||||
return `${v.toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function toNum(v: any): number | undefined {
|
||||
if (typeof v === 'number') return v
|
||||
if (typeof v === 'string') {
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function formatBytesMaybe(v: any): string {
|
||||
const n = toNum(v)
|
||||
if (n === undefined) return '-'
|
||||
return formatBytes(n)
|
||||
}
|
||||
|
||||
function getMemoryColor(percent: number): string {
|
||||
if (percent < 50) return '#52c41a'
|
||||
if (percent < 80) return '#faad14'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables';
|
||||
|
||||
.system-monitor {
|
||||
padding: 24px;
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
min-height: 100vh;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.title-icon {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 监控内容
|
||||
.monitor-content {
|
||||
|
||||
// 刷新按钮
|
||||
.refresh-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
// 概览卡片
|
||||
.overview-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
// 布局类
|
||||
&.overview-3col {
|
||||
// 3列布局(默认)
|
||||
}
|
||||
|
||||
&.overview-2col {
|
||||
// 2列布局
|
||||
}
|
||||
|
||||
&.overview-4col {
|
||||
// 4列布局
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
// 系统信息样式
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--theme-primary, #3b82f6);
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CPU统计样式
|
||||
.cpu-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.cpu-usage {
|
||||
color: var(--theme-primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cpu-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
border-radius: 6px;
|
||||
|
||||
.detail-label {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存进度样式
|
||||
.memory-progress {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
}
|
||||
}
|
||||
|
||||
.memory-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.memory-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
border-radius: 6px;
|
||||
|
||||
.memory-label {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.memory-value {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPU不可用样式
|
||||
.gpu-unavailable {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gpu-timestamp {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary, #64748b);
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 详细监控区域
|
||||
.detail-sections {
|
||||
// 布局类
|
||||
&.detail-2col {
|
||||
// 2列布局(默认)
|
||||
}
|
||||
|
||||
&.detail-full {
|
||||
// 全宽布局
|
||||
}
|
||||
|
||||
&.detail-side {
|
||||
// 并排布局
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: var(--theme-card-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 400px;
|
||||
height: 100%;
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--theme-primary, #3b82f6) 0%, var(--theme-primary, #2563eb) 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
// 表格样式优化
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
border-bottom: 1px solid var(--theme-card-border, #e5e7eb);
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--theme-card-border, #f1f5f9);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--theme-content-bg, #f8fafc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3620
hertz_server_django_ui/src/views/user_pages/YoloDetection.vue
Normal file
3620
hertz_server_django_ui/src/views/user_pages/YoloDetection.vue
Normal file
File diff suppressed because it is too large
Load Diff
7122
hertz_server_django_ui/src/views/user_pages/index.vue
Normal file
7122
hertz_server_django_ui/src/views/user_pages/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
1
hertz_server_django_ui/src/vite-env.d.ts
vendored
Normal file
1
hertz_server_django_ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
hertz_server_django_ui/tsconfig.app.json
Normal file
22
hertz_server_django_ui/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting - 放宽限制以减少WebStorm警告 */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noImplicitReturns": false,
|
||||
"noImplicitOverride": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
hertz_server_django_ui/tsconfig.json
Normal file
7
hertz_server_django_ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
hertz_server_django_ui/tsconfig.node.json
Normal file
25
hertz_server_django_ui/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting - 放宽限制以减少WebStorm警告 */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
333
hertz_server_django_ui/vite.config.ts
Normal file
333
hertz_server_django_ui/vite.config.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { defineConfig, type Plugin, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import fs from 'fs'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// https://vite.dev/config/
|
||||
// 生成 public/models/manifest.json,自动列举 .onnx 文件
|
||||
function modelsManifestPlugin(): Plugin {
|
||||
const writeManifest = () => {
|
||||
try {
|
||||
const modelsDir = resolve(__dirname, 'public/models')
|
||||
if (!fs.existsSync(modelsDir)) return
|
||||
const files = fs
|
||||
.readdirSync(modelsDir)
|
||||
.filter((f) => f.toLowerCase().endsWith('.onnx'))
|
||||
const manifestPath = resolve(modelsDir, 'manifest.json')
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(files, null, 2))
|
||||
console.log(`📦 models manifest updated (${files.length}):`, files)
|
||||
} catch (e) {
|
||||
console.warn('⚠️ update models manifest failed:', (e as any)?.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'models-manifest',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
writeManifest()
|
||||
const dir = resolve(__dirname, 'public/models')
|
||||
try {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.watch(dir, { persistent: true }, (_event, filename) => {
|
||||
if (!filename) return
|
||||
if (filename.toLowerCase().endsWith('.onnx')) writeManifest()
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
buildStart() {
|
||||
writeManifest()
|
||||
},
|
||||
closeBundle() {
|
||||
writeManifest()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:3000'
|
||||
const backendOrigin = apiBaseUrl.replace(/\/+$/, '')
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
modelsManifestPlugin(),
|
||||
Components({
|
||||
resolvers: [
|
||||
AntDesignVueResolver({
|
||||
importStyle: false, // css in js
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'~': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 新增:允许所有网络接口访问
|
||||
port: 3001, // 明确设置为3001端口
|
||||
open: true,
|
||||
cors: true,
|
||||
proxy: {
|
||||
// RSS新闻代理转发到百度新闻(需要放在/api之前,优先匹配)
|
||||
'/api/rss': {
|
||||
target: 'https://news.baidu.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
timeout: 10000, // 设置10秒超时
|
||||
rewrite: (path) => {
|
||||
// 百度新闻RSS格式: /n?cmd=1&class=类别&tn=rss
|
||||
// 支持多种RSS路径
|
||||
if (path.includes('/world')) {
|
||||
return '/n?cmd=1&class=internet&tn=rss' // 国际新闻
|
||||
} else if (path.includes('/tech')) {
|
||||
return '/n?cmd=1&class=technic&tn=rss' // 科技新闻
|
||||
} else if (path.includes('/domestic')) {
|
||||
return '/n?cmd=1&class=civilnews&tn=rss' // 国内新闻
|
||||
} else if (path.includes('/finance')) {
|
||||
return '/n?cmd=1&class=finance&tn=rss' // 财经新闻
|
||||
}
|
||||
// 默认使用国内新闻
|
||||
return '/n?cmd=1&class=civilnews&tn=rss'
|
||||
},
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 添加必要的请求头,模拟浏览器请求
|
||||
proxyReq.setHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
proxyReq.setHeader('Accept', 'application/xml, text/xml, */*')
|
||||
proxyReq.setHeader('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')
|
||||
proxyReq.setHeader('Referer', 'https://news.baidu.com/')
|
||||
proxyReq.setHeader('Host', 'news.baidu.com')
|
||||
// 移除Origin,避免CORS问题
|
||||
proxyReq.removeHeader('Origin')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`📰 RSS代理请求: ${req.method} ${req.url} -> ${proxyReq.path}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 添加CORS头部
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type')
|
||||
|
||||
// 确保Content-Type正确
|
||||
if (proxyRes.headers['content-type']) {
|
||||
res.setHeader('Content-Type', proxyRes.headers['content-type'])
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/xml; charset=utf-8')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ RSS响应: ${proxyRes.statusCode} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('❌ RSS代理错误:', err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
// 翻译API代理转发到腾讯翻译(需要放在/api之前,优先匹配)
|
||||
'/api/translate': {
|
||||
target: 'https://fanyi.qq.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
timeout: 10000, // 设置10秒超时
|
||||
rewrite: (path) => {
|
||||
// 腾讯翻译接口路径是 /api/translate,需要保留所有查询参数
|
||||
const pathWithoutPrefix = path.replace(/^\/api\/translate/, '/api/translate')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('翻译代理路径重写:', path, '->', pathWithoutPrefix)
|
||||
}
|
||||
return pathWithoutPrefix
|
||||
},
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 添加必要的请求头,模拟浏览器请求
|
||||
proxyReq.setHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
proxyReq.setHeader('Accept', 'application/json, text/plain, */*')
|
||||
proxyReq.setHeader('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')
|
||||
proxyReq.setHeader('Referer', 'https://fanyi.qq.com/')
|
||||
proxyReq.setHeader('Content-Type', 'application/json; charset=UTF-8')
|
||||
// 移除Origin,避免CORS问题
|
||||
if (proxyReq.getHeader('Origin')) {
|
||||
proxyReq.removeHeader('Origin')
|
||||
}
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🌐 翻译代理请求: ${req.method} ${req.url} -> ${proxyReq.path}`)
|
||||
console.log(`🌐 代理目标: https://fanyi.qq.com${proxyReq.path}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 添加CORS头部
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Type')
|
||||
|
||||
// 确保Content-Type正确
|
||||
if (proxyRes.headers['content-type']) {
|
||||
res.setHeader('Content-Type', proxyRes.headers['content-type'])
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ 翻译响应: ${proxyRes.statusCode} ${req.url}`)
|
||||
// 如果是错误状态码,记录详细信息
|
||||
if (proxyRes.statusCode >= 400) {
|
||||
console.error(`❌ 翻译API错误: ${proxyRes.statusCode} ${req.url}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('❌ 翻译代理错误:', err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
// 天气API代理转发到中国气象局(需要放在/api之前,优先匹配)
|
||||
'/api/weather': {
|
||||
target: 'https://weather.cma.cn',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/weather/, '/api/weather'),
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 添加必要的请求头,模拟浏览器请求
|
||||
proxyReq.setHeader('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
proxyReq.setHeader('Accept', 'application/json, text/plain, */*')
|
||||
proxyReq.setHeader('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8')
|
||||
proxyReq.setHeader('Referer', 'https://weather.cma.cn/')
|
||||
proxyReq.setHeader('Origin', 'https://weather.cma.cn')
|
||||
proxyReq.setHeader('X-Proxy-By', 'Vite-Dev-Server')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🌤️ 天气API代理: ${req.method} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 添加CORS头部
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ 天气API响应: ${proxyRes.statusCode} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('❌ 天气API代理错误:', err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
// API代理转发到后端服务器
|
||||
'/api': {
|
||||
target: backendOrigin,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
||||
// 优化Network面板显示
|
||||
// 保持原始头部信息
|
||||
preserveHeaderKeyCase: true,
|
||||
// 添加CORS头部,改善Network面板显示
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE,PATCH,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Content-Length, X-Requested-With',
|
||||
},
|
||||
configure: (proxy, options) => {
|
||||
// 简化代理日志
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 添加标识头部,帮助Network面板识别
|
||||
proxyReq.setHeader('X-Proxy-By', 'Vite-Dev-Server')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🔄 代理: ${req.method} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 添加响应头部,改善Network面板显示
|
||||
res.setHeader('X-Proxy-By', 'Vite-Dev-Server')
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ 响应: ${proxyRes.statusCode} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('❌ 代理错误:', err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
// 媒体文件代理转发到后端服务器
|
||||
'/media': {
|
||||
target: backendOrigin,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/media/, '/media'),
|
||||
// 优化Network面板显示
|
||||
// 保持原始头部信息
|
||||
preserveHeaderKeyCase: true,
|
||||
// 添加CORS头部,改善Network面板显示
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE,PATCH,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Content-Length, X-Requested-With',
|
||||
},
|
||||
configure: (proxy, options) => {
|
||||
// 简化代理日志
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 添加标识头部,帮助Network面板识别
|
||||
proxyReq.setHeader('X-Proxy-By', 'Vite-Dev-Server')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`🔄 媒体代理: ${req.method} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 添加响应头部,改善Network面板显示
|
||||
res.setHeader('X-Proxy-By', 'Vite-Dev-Server')
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ 媒体响应: ${proxyRes.statusCode} ${req.url}`)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.error('❌ 媒体代理错误:', err.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// 环境变量定义,确保在没有.env文件时也能正常工作
|
||||
__VITE_API_BASE_URL__: JSON.stringify(`${backendOrigin}/api`),
|
||||
__VITE_APP_TITLE__: JSON.stringify('Hertz Admin'),
|
||||
__VITE_APP_VERSION__: JSON.stringify('1.0.0'),
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
antd: ['ant-design-vue'],
|
||||
utils: ['axios', 'echarts'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
422
hertz_server_django_ui/修改操作指南.md
Normal file
422
hertz_server_django_ui/修改操作指南.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# 前端样式 / 布局 / UI 修改操作指南
|
||||
|
||||
> 面向二次开发前端,告诉你:**改样式 / 改布局 / 改 UI 具体要动哪些文件、怎么改**。
|
||||
>
|
||||
> 项目技术栈:Vite + Vue 3 + TypeScript + Ant Design Vue + Pinia + Vue Router + SCSS
|
||||
|
||||
---
|
||||
|
||||
## 🧭 速查表
|
||||
|
||||
- **改全局颜色 / 按钮 / 弹窗风格**:看第 1 章「整体样式体系总览」(`src/styles/index.scss` + `src/styles/variables.scss`)
|
||||
- **改管理端整体布局(侧边栏、头部、内容区排版)**:看 2.1「管理端整体布局」(`src/views/admin_page/index.vue`)
|
||||
- **改用户端整体布局(顶部导航 + 内容容器)」**:看 2.2「用户端整体布局」(`src/views/user_pages/index.vue`)
|
||||
- **改 YOLO 检测排版 / 三种布局 / 卡片样式**:看第 3 章「YOLO 检测页面修改指南」(`src/views/user_pages/YoloDetection.vue`)
|
||||
- **改 AI 助手聊天布局**:看第 4 章「修改 AI 助手页面」(`src/views/user_pages/AiChat.vue`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体样式体系总览
|
||||
|
||||
### 1.1 全局样式入口
|
||||
|
||||
- 入口文件:`src/styles/index.scss`
|
||||
- 在 `src/main.ts` 中全局引入:
|
||||
- `import './styles/index.scss'`
|
||||
- 主要职责:
|
||||
- 重置 margin / padding / box-sizing
|
||||
- 全局字体、`html, body, #app` 基础样式
|
||||
- 自定义 `.btn` / `.card` 等通用类
|
||||
- 全局 Ant Design Vue 主题风格覆盖(如 `.ant-modal`, `.ant-btn` 等)
|
||||
|
||||
**如果你要改全局的按钮、弹窗、表单、输入框等基础风格:**
|
||||
|
||||
1. 打开 `src/styles/index.scss`
|
||||
2. 找对应的选择器:
|
||||
- 按钮:`.ant-btn` 下的几种状态(`&.ant-btn-default` / `&.ant-btn-primary` / `&.ant-btn-dangerous` 等)
|
||||
- 弹窗:`.ant-modal` 内的 `.ant-modal-content` / `.ant-modal-header` / `.ant-modal-footer`
|
||||
- 输入/选择等:`.ant-input`, `.ant-select-selector`, `.ant-input-number`, `.ant-picker` 等
|
||||
3. 直接在这里调整颜色、圆角、阴影、间距。
|
||||
4. 样式会作用于所有页面,无需在每个 `.vue` 里重复写。
|
||||
|
||||
> 建议:全局 Design System 统一改在这里,不要在业务页面里到处改 AntD 默认样式。
|
||||
|
||||
### 1.2 变量和混合(主题基础)
|
||||
|
||||
- 文件:`src/styles/variables.scss`
|
||||
- 主要内容:
|
||||
- 颜色:`$primary-color`、`$success-color`、`$gray-xxx` 等
|
||||
- 间距:`$spacing-1 ~ $spacing-20`
|
||||
- 圆角:`$radius-md`、`$radius-lg` 等
|
||||
- 阴影:`$shadow-sm` / `$shadow-md` / `$shadow-lg`
|
||||
- 常用 mixin:`@mixin card-style`、`@mixin button-style` 等
|
||||
|
||||
**改全局配色 / 圆角 / 阴影的操作方式:**
|
||||
|
||||
1. 打开 `src/styles/variables.scss`
|
||||
2. 修改对应变量:
|
||||
- 主色:`$primary-color` / `$primary-light` / `$primary-dark`
|
||||
- 背景:`$bg-primary` / `$bg-secondary`
|
||||
- 阴影:`$shadow-md` / `$shadow-lg`
|
||||
3. 不需要修改业务页面,使用这些变量的地方会统一生效。
|
||||
|
||||
> 如果要在页面里复用统一卡片/按钮样式,可以直接:
|
||||
>
|
||||
> ```scss
|
||||
> .my-card {
|
||||
> @include card-style;
|
||||
> }
|
||||
>
|
||||
> .my-primary-button {
|
||||
> @include button-style($primary-color, #fff);
|
||||
> }
|
||||
> ```
|
||||
|
||||
### 1.3 主题 store 与 CSS 变量
|
||||
|
||||
- 文件:`src/stores/hertz_theme.ts`
|
||||
- 作用:
|
||||
- 定义 `ThemeConfig`(导航栏背景、页面背景、卡片背景、主色、文字颜色)
|
||||
- 使用 `document.documentElement.style.setProperty` 写入 CSS 变量:
|
||||
- `--theme-header-bg`, `--theme-page-bg`, `--theme-card-bg`, `--theme-primary`, `--theme-text-primary` 等
|
||||
- 使用方式:
|
||||
- 在页面/组件的 SCSS 中,通过 `var(--theme-primary)` 等变量引用主题色:
|
||||
- 示例:`color: var(--theme-text-primary, #1e293b);`
|
||||
|
||||
**修改主题默认值**:
|
||||
|
||||
1. 打开 `src/stores/hertz_theme.ts`
|
||||
2. 修改 `defaultTheme` 对象里的颜色值即可:
|
||||
- 如:`primaryColor: '#FF4D4F'` 改成你的品牌色
|
||||
3. 调用 `themeStore.loadTheme()` 时会自动应用到全局。
|
||||
|
||||
**页面内如何用这些主题变量?**
|
||||
|
||||
- 在 SCSS 中使用:
|
||||
|
||||
```scss
|
||||
.some-block {
|
||||
background: var(--theme-card-bg, #fff);
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
border-color: var(--theme-card-border, #e5e7eb);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 布局结构:管理端 / 用户端
|
||||
|
||||
### 2.1 管理端整体布局
|
||||
|
||||
- 入口布局:`src/views/admin_page/index.vue`
|
||||
- 结构:
|
||||
- 外层 `.admin-layout`
|
||||
- 使用 `a-layout` + `a-layout-sider` + `a-layout-header` + `a-layout-content` + `a-layout-footer`
|
||||
- 侧边菜单:`a-layout-sider` 内的 `a-menu`,使用 `admin_menu.ts` 生成菜单项
|
||||
|
||||
**修改管理端整体布局方式(比如侧边栏宽度、顶部高度):**
|
||||
|
||||
1. 打开 `src/views/admin_page/index.vue`
|
||||
2. 找到模板部分:
|
||||
- 侧边栏:`<a-layout-sider ... class="admin-sider">`
|
||||
- 顶部:`<a-layout-header class="header">`
|
||||
- 内容:`<a-layout-content class="content">`
|
||||
3. 在同文件底部的 `<style scoped lang="scss">` 中调整:
|
||||
- `.admin-layout`、`.admin-sider`、`.header`、`.content` 的 padding / background / shadow 等。
|
||||
4. 如果要改变菜单布局(比如改成顶部导航):需要同时更新 template 结构和对应的 SCSS。
|
||||
|
||||
### 2.2 用户端整体布局
|
||||
|
||||
- 入口布局:`src/views/user_pages/index.vue`
|
||||
- 结构:
|
||||
- 顶部 `a-layout-header.user-header`:包含 logo + 顶部菜单 + 面包屑 + 布局切换按钮 + 主题按钮 + 用户下拉
|
||||
- 主体 `a-layout.main-layout`,内部 `a-layout-content.user-content` 作为页面内容容器
|
||||
- 中间区域通过 `currentComponent` 动态切换不同业务页(YOLO 检测、AI 助手等)
|
||||
|
||||
**修改用户端整体布局(例如 header 高度、主内容宽度):**
|
||||
|
||||
1. 打开 `src/views/user_pages/index.vue`
|
||||
2. 在 template 里找到:
|
||||
- `<a-layout-header class="user-header">`
|
||||
- `<a-layout class="main-layout">`
|
||||
- `<a-layout-content class="user-content">`
|
||||
3. 在同文件的 `<style scoped lang="scss">` 中调整:
|
||||
- `.user-layout`、`.user-header`、`.main-layout`、`.user-content`、`.content-wrapper`
|
||||
4. 你也可以在这里添加全局背景图(比如 `.user-layout` 里加 background),只作用于用户端。
|
||||
|
||||
> 所有用户端的业务页面(`YoloDetection.vue`、`AiChat.vue` 等)都是渲染在 `user-content` 容器里,尽量保持这里的 padding / 背景一致,具体视觉再在业务页内做细化。
|
||||
|
||||
---
|
||||
|
||||
## 3. YOLO 检测页面修改指南(排版 / 布局 / 样式 / UI)
|
||||
|
||||
文件路径:`src/views/user_pages/YoloDetection.vue`
|
||||
|
||||
这个页面已经内置了 **三种布局模式**,并且使用了大量局部 SCSS + AntD 组件。下面分步骤说明如何操作。
|
||||
|
||||
### 3.1 布局模式(classic / vertical / grid)
|
||||
|
||||
关键代码:
|
||||
|
||||
- 布局枚举与状态:
|
||||
|
||||
```ts
|
||||
const layoutMode = ref<'classic' | 'vertical' | 'grid'>('classic')
|
||||
const layoutKey = ref(0)
|
||||
```
|
||||
|
||||
- 模板中的三个布局分支:
|
||||
|
||||
```vue
|
||||
<!-- 经典两列布局 -->
|
||||
<div v-if="layoutMode === 'classic'" class="detection-content classic-layout" :key="`classic-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
|
||||
<!-- 上下布局 -->
|
||||
<div v-if="layoutMode === 'vertical'" class="detection-content vertical-layout" :key="`vertical-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏详情布局 -->
|
||||
<div v-if="layoutMode === 'grid'" class="detection-content sidebar-layout" :key="`grid-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
`layoutMode` 的切换来自用户端首页布局弹窗(`user_pages/index.vue`),会写入 `localStorage('yoloDetectionLayout')`。
|
||||
|
||||
**如果你要改某一种布局的排版:**
|
||||
|
||||
1. 在 `YoloDetection.vue` 模板里定位对应的 `div`:
|
||||
- 经典两列:`.classic-layout`
|
||||
- 上下布局:`.vertical-layout`
|
||||
- 侧边栏布局:`.sidebar-layout`
|
||||
2. 在同文件的 `<style scoped lang="scss">` 中搜索这些 class(例如搜索 `.classic-layout`):
|
||||
- 调整其中的 `a-row` / `a-col` 排布、卡片宽度、高度等。
|
||||
3. 如果你只想保留一种布局,比如只保留经典两列:
|
||||
- 可以在 template 中暂时注释掉 `vertical` 和 `grid` 这两段。
|
||||
- 或者在 script 中将 `layoutMode` 的类型改小,只允许 `'classic'`。
|
||||
|
||||
### 3.2 修改上传区域(左侧卡片)
|
||||
|
||||
上传部分主结构(classic 布局示例):
|
||||
|
||||
```vue
|
||||
<a-card title="文件上传" class="upload-card">
|
||||
<div class="upload-area">
|
||||
<a-upload-dragger ... class="beautiful-upload">
|
||||
...
|
||||
</a-upload-dragger>
|
||||
|
||||
<div v-if="fileList.length > 0" class="compact-preview">...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detection-params" v-if="fileList.length > 0">...
|
||||
</div>
|
||||
|
||||
<div class="upload-actions" v-if="fileList.length > 0">...
|
||||
</div>
|
||||
</a-card>
|
||||
```
|
||||
|
||||
对应样式(节选):
|
||||
|
||||
```scss
|
||||
.yolo-detection-page {
|
||||
.upload-card { ... }
|
||||
.upload-area { ... }
|
||||
.beautiful-upload { ... }
|
||||
.compact-preview { ... }
|
||||
.upload-actions { ... }
|
||||
}
|
||||
|
||||
// 深度作用 AntD Upload
|
||||
:deep(.ant-upload-dragger) {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**具体改法示例:**
|
||||
|
||||
- **想调整拖拽区域高度 / 圆角 / 背景:**
|
||||
1. 在 `<style scoped lang="scss">` 中搜索 `:deep(.ant-upload-dragger)`。
|
||||
2. 修改其中的 `border-radius`、`min-height`、`background` 等。
|
||||
|
||||
- **想改变已上传文件的缩略图排列方式:**
|
||||
1. 搜索 `.compact-preview`、`.preview-grid`、`.preview-card` 等 class。
|
||||
2. 例如,将 `display: grid` 的列数从 `3` 改为 `4`:
|
||||
- 修改 `grid-template-columns: repeat(3, 1fr);` 为 `repeat(4, 1fr);`。
|
||||
|
||||
- **想给“开始检测”按钮加一个 loading 状态颜色:**
|
||||
1. 上传按钮使用的是 `<a-button type="primary" :loading="detecting">`,全局颜色来自 `.ant-btn-primary`。
|
||||
2. 如果想只在该页面定制:
|
||||
|
||||
```scss
|
||||
.yolo-detection-page {
|
||||
.upload-actions {
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #22c55e; // 单独改为绿色
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 修改检测结果区域排版
|
||||
|
||||
经典布局右侧结果卡片结构:
|
||||
|
||||
```vue
|
||||
<a-card title="检测结果" class="result-card">
|
||||
<div v-if="detectionResults.length === 0" class="no-results">...</div>
|
||||
<div v-else class="results-list">
|
||||
<div class="results-grid">
|
||||
<div v-for="result in sortedDetectionResults" class="result-item">
|
||||
<div class="image-comparison">...
|
||||
<div class="result-details">...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
```
|
||||
|
||||
对应样式关键点:
|
||||
|
||||
- `.results-list`:控制滚动区域、高度和滚动条样式
|
||||
- `.result-item`:单个结果卡片的背景、边框、阴影
|
||||
- `.image-comparison` / `.image-pair` / `.image-wrapper`:左右对比图/视频布局
|
||||
- `.result-details` / `.detection-stats` / `.detection-tags`:文本统计信息
|
||||
|
||||
**操作示例:**
|
||||
|
||||
- **改结果区域整体高度 / 是否滚动:**
|
||||
1. 在 SCSS 中搜索 `.results-list`。
|
||||
2. 调整 `max-height` 或移除 `overflow-y: scroll !important;`,就可以变成自适应高度。
|
||||
|
||||
- **把“原图/检测结果”改成上下叠加而不是左右:**
|
||||
1. 找到 `.image-pair`:
|
||||
|
||||
```scss
|
||||
.image-pair {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
2. 改为列方向:
|
||||
|
||||
```scss
|
||||
.image-pair {
|
||||
display: flex;
|
||||
flex-direction: column; // 上下排列
|
||||
gap: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
- **调整图片容器比例 / 加圆角阴影:**
|
||||
1. 修改 `.image-wrapper`:`height: 160px;` 改为你想要的高度或使用 `aspect-ratio`。
|
||||
2. 修改 `border-radius` / `box-shadow` 实现更柔和的卡片效果。
|
||||
|
||||
### 3.4 深度选择器 `:deep` 使用说明
|
||||
|
||||
在 `YoloDetection.vue` 中多次使用了 `:deep(...)` 来覆盖 AntD 子组件样式,例如:
|
||||
|
||||
```scss
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-list) {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
**规则:**
|
||||
|
||||
- 页面级样式修改尽量写在该 `.vue` 的 `<style scoped>` 中,用 `:deep` 定位到 AntD 的 DOM 结构。
|
||||
- 全局通用样式统一放在 `src/styles/index.scss`,不要在页面里频繁写过多全局覆盖。
|
||||
|
||||
**如何找到 AntD 的 class 名称?**
|
||||
|
||||
1. 浏览器打开页面,F12 查看对应组件 DOM 结构。
|
||||
2. 复制最内层需要修改的 class(如 `.ant-card-head`)。
|
||||
3. 在该页面样式中写:
|
||||
|
||||
```scss
|
||||
.my-custom-card {
|
||||
:deep(.ant-card-head) {
|
||||
// 自定义头部样式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 修改 AI 助手页面(整体方法类似)
|
||||
|
||||
AI 助手页面:`src/views/user_pages/AiChat.vue`
|
||||
|
||||
- 也有多种布局:`default`(左右)、`compact`(上下)、`wide`(全屏消息 + 浮动侧边栏)。
|
||||
- 样式组织方式与 YOLO 检测类似:
|
||||
- 外层 `.ai-chat-page`
|
||||
- 内部 `.chat-container` / `.chat-sidebar` / `.chat-main` / `.messages-wrapper` / `.composer` 等
|
||||
- 使用 `:deep` 覆盖 AntD 的 `a-card`、`a-input-search`、`a-list` 等组件
|
||||
|
||||
**如果你已经掌握了上面 YOLO 页面改法:**
|
||||
|
||||
- 改 AI 助手排版时只需:
|
||||
1. 找到对应布局分支(`currentLayout === 'default' | 'compact' | 'wide'`)
|
||||
2. 在 `<style scoped lang="scss">` 中编辑对应 class 的样式即可。
|
||||
|
||||
---
|
||||
|
||||
## 5. 实战建议:改 UI 的基本步骤
|
||||
|
||||
1. **先确认层级:**
|
||||
- 是全局都要变?(颜色、按钮、弹窗 -> `styles/index.scss`, `styles/variables.scss`, `hertz_theme.ts`)
|
||||
- 只改某个模块?(YOLO / AI / 管理端 / 用户端布局 -> 各自 `.vue` 的 scoped 样式)
|
||||
|
||||
2. **用浏览器开发者工具查 DOM 和 class:**
|
||||
- 看清楚 AntD 组件最终渲染出的结构和 class
|
||||
- 再决定用 `:deep(.ant-xxx)` 还是自定义的 `.my-block` 去写样式
|
||||
|
||||
3. **在对应 `.vue` 文件底部 `<style scoped lang="scss">` 中改局部样式:**
|
||||
- 保持 class 命名有语义:`.yolo-detection-page`、`.upload-area`、`.results-list` 等
|
||||
|
||||
4. **共用样式尽量抽到全局:**
|
||||
- 例如卡片风格在 `variables.scss` + `index.scss` 抽成 mixin 或全局 class
|
||||
|
||||
5. **改完后检查响应式:**
|
||||
- `YoloDetection.vue` 和 AI 助手都内置了 `@media` 段,记得同步修改,以免移动端错位。
|
||||
|
||||
---
|
||||
|
||||
## 6. 如果要新增一个“风格类似 YOLO 的新页面”
|
||||
|
||||
1. 在 `src/views/user_pages/` 下新建 `MyFeature.vue`
|
||||
2. 参考 `YoloDetection.vue` 的结构:
|
||||
- 顶部 `.page-header`
|
||||
- 中间 `a-row` + `a-col` 做主布局
|
||||
- 底部 `<style scoped lang="scss">` 里用 `.my-feature-page { ... }` 包裹所有样式
|
||||
3. 使用全局变量和 mixin:
|
||||
|
||||
```scss
|
||||
.my-feature-page {
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
|
||||
.my-card {
|
||||
@include card-style;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 在 `src/router/user_menu_ai.ts` 中为用户端菜单新增一项,对应 `MyFeature.vue`。
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user