This commit is contained in:
2025-12-13 15:41:17 +08:00
parent 2d0691153d
commit 00b52253a8
149 changed files with 164 additions and 128745 deletions

View 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

View 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

View File

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

View 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
View 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?

View 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 3Composition API
- UIAnt 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
View 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']
}
}

View 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',
]
}
]

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1 @@
[]

View 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

View File

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

View File

@@ -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>

View 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),
}

View 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/')
}

View 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
}
}

View 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'
}
}

View 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/')
}
}

View 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'

View File

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

View File

@@ -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/`)
},
}

View 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 } })
},
}

View 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 })
}
}

View 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 }),
}

View 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 })
}

View 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/')
}
}

View 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/'),
}

View 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/')
}
}

View 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

View File

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

View File

@@ -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',
},
}

View 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

View 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: '系统配置错误,请联系管理员',
},
}

View 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')

View 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);
};

View 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;

View 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))
}

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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; }
}
}

View 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%);
}
}

View 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
}

View 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
}

View 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 }

View 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',
}
}

View 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)
}

View 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)
}
}

View 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 }

View 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('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
}

View 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)
}

View 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)
}
},
}

View 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
}

View 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优先 GPUWebGPU/WebGL再回退 WASM
// 支持通过 localStorage 开关强制使用 WASMlocalStorage.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) WebGLGPU 加速,兼容更好)
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
try {
await createWithEP('webgl')
created = true
console.log('✅ 使用 WebGL 推理')
} catch (e2) {
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
}
}
// 3) WASMCPU
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()

View 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>&copy; 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>

View 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>

View File

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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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'],
},
},
},
},
}
})

View 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`
---