重新提交
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
# 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
|
||||
@@ -1,10 +0,0 @@
|
||||
# API 基础地址
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=Hertz Admin
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# 开发服务器配置
|
||||
VITE_DEV_SERVER_HOST=localhost
|
||||
VITE_DEV_SERVER_PORT=3000
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_TEMPLATE_SETUP_MODE=true
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_TEMPLATE_SETUP_MODE=true
|
||||
24
hertz_server_django_ui/.gitignore
vendored
24
hertz_server_django_ui/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,327 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<h1>通用大模型模板 · Hertz Admin + AI</h1>
|
||||
|
||||
现代化的管理后台前端模板,面向二次开发的前端工程师。内置账号体系、权限路由、主题美化、知识库、YOLO 模型全流程(管理 / 类别 / 告警 / 历史)等典型模块。
|
||||
|
||||
<p>
|
||||
基于 Vite + Vue 3 + TypeScript + Ant Design Vue + Pinia + Vue Router 构建
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性(面向前端)
|
||||
|
||||
- **工程化完善**:TS 强类型、模块化 API、统一请求封装、权限化菜单/路由
|
||||
- **设计统一**:全局“超现代风格”主题,卡片 / 弹窗 / 按钮 / 输入 / 分页风格一致
|
||||
- **业务可复用**:
|
||||
- 文章管理:分类树 + 列表搜索 + 编辑/发布
|
||||
- YOLO 模型:模型管理、模型类别管理、告警处理中心、检测历史管理
|
||||
- AI 助手:多会话列表 + 消息记录 + 多布局对话界面(含错误调试信息)
|
||||
- 认证体系:登录/注册、验证码
|
||||
- **可扩展**:清晰的目录划分和命名规范,方便直接加模块或替换现有实现
|
||||
|
||||
## 🧩 技术栈
|
||||
|
||||
- 构建:Vite
|
||||
- 语言:TypeScript
|
||||
- 框架:Vue 3(Composition API)
|
||||
- UI:Ant Design Vue
|
||||
- 状态:Pinia
|
||||
- 路由:Vue Router
|
||||
|
||||
## 📦 项目结构与职责
|
||||
|
||||
> 根目录:`通用大模型模板/`
|
||||
|
||||
```bash
|
||||
通用大模型模板/
|
||||
└─ hertz_server_diango_ui_2/ # 前端工程(Vite)
|
||||
├─ public/ # 公共静态资源(不走打包器)
|
||||
├─ src/
|
||||
│ ├─ api/ # 接口定义(auth / yolo / knowledge / captcha / ai ...)
|
||||
│ │ └─ yolo.ts # YOLO 模型 & 检测 & 类别相关 API
|
||||
│ ├─ locales/ # 国际化文案
|
||||
│ ├─ router/ # 路由与菜单配置
|
||||
│ │ ├─ admin_menu.ts # 管理端菜单 + 路由映射(权限 key)
|
||||
│ │ ├─ user_menu_ai.ts # 用户端菜单 + 路由映射(含 AI 助手)
|
||||
│ │ └─ index.ts # Vue Router 实例 + 全局路由守卫
|
||||
│ ├─ stores/ # Pinia Store
|
||||
│ │ ├─ hertz_app.ts # 全局应用设置(语言、布局、菜单折叠等)
|
||||
│ │ ├─ hertz_user.ts # 用户 / 鉴权状态
|
||||
│ │ └─ hertz_theme.ts # 主题配置与 CSS 变量
|
||||
│ ├─ styles/ # 全局样式与变量
|
||||
│ │ ├─ index.scss # 全局组件风格覆盖(Button / Table / Modal ...)
|
||||
│ │ └─ variables.scss # 主题色、阴影、圆角等变量
|
||||
│ ├─ utils/ # 工具方法 & 基础设施
|
||||
│ │ ├─ hertz_request.ts # Axios 封装(baseURL、拦截器、错误提示)
|
||||
│ │ ├─ hertz_url.ts # 统一 URL 构造(API / 媒体 / WebSocket)
|
||||
│ │ ├─ hertz_env.ts # 读取 & 校验 env 变量
|
||||
│ │ └─ hertz_router_utils.ts # 路由相关工具 & 调试
|
||||
│ ├─ views/ # 所有页面
|
||||
│ │ ├─ admin_page/ # 管理端页面
|
||||
│ │ │ ├─ ModelManagement.vue # YOLO 模型管理
|
||||
│ │ │ ├─ AlertLevelManagement.vue # 模型类别管理
|
||||
│ │ │ ├─ DetectionHistoryManagement.vue # 检测历史管理
|
||||
│ │ │ └─ ... # 其他管理端模块
|
||||
│ │ ├─ user_pages/ # 用户端页面(检测端 + AI 助手)
|
||||
│ │ │ ├─ index.vue # 用户端主布局 + 顶部导航
|
||||
│ │ │ ├─ AiChat.vue # AI 助手对话页面
|
||||
│ │ │ ├─ YoloDetection.vue # 离线检测页面
|
||||
│ │ │ ├─ LiveDetection.vue # 实时检测页面(WebSocket)
|
||||
│ │ │ └─ ... # 告警中心 / 通知中心 / 知识库等
|
||||
│ │ ├─ Login.vue # 登录页
|
||||
│ │ └─ register.vue # 注册页
|
||||
│ ├─ App.vue # 应用根组件
|
||||
│ └─ main.ts # 入口文件(挂载 Vue / 路由 / Pinia)
|
||||
├─ .env.development # 开发环境变量(前端专用)
|
||||
├─ .env.production # 生产构建环境变量
|
||||
├─ vite.config.ts # Vite 配置(代理、构建、别名等)
|
||||
└─ package.json
|
||||
```
|
||||
|
||||
## 📁 文件与命名规范(建议)
|
||||
|
||||
- **组件 / 页面**
|
||||
- 页面:`src/views/admin_page/FooBarManagement.vue`,以业务 + Management 命名
|
||||
- 纯组件:放到 `src/components/`,使用大驼峰命名,如 `UserSelector.vue`
|
||||
- **接口文件**
|
||||
- 同一业务一个文件:`src/api/yolo.ts`、`src/api/auth.ts`
|
||||
- 内部导出 `xxxApi` 对象 + TS 类型:`type AlertLevel`, `type YoloModel` 等
|
||||
- **样式**
|
||||
- 全局或主题相关:放 `src/styles/`(注意不要在这里写页面私有样式)
|
||||
- 单页面样式:使用 `<style scoped lang="scss">` 写在对应 `.vue` 内
|
||||
- **工具函数**
|
||||
- 通用工具:`src/utils/` 下按领域拆分,如 `hertz_url.ts`、`hertz_env.ts`
|
||||
|
||||
## 🌐 后端 IP / 域名配置指引(前端视角最重要)
|
||||
|
||||
当前工程已经统一了后端地址配置,只需要 **改 2 个地方**:
|
||||
|
||||
1. **环境变量文件**(推荐只改这个)
|
||||
|
||||
- `hertz_server_diango_ui_2/.env.development`
|
||||
- `hertz_server_diango_ui_2/.env.production`
|
||||
|
||||
两个文件里都有一行:
|
||||
|
||||
```bash
|
||||
# 示例:开发环境
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- **只写协议 + 域名/IP + 端口**,不要包含 `/api`
|
||||
- ✅ `http://localhost:8000`
|
||||
- ❌ `http://localhost:8000/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
80
hertz_server_django_ui/components.d.ts
vendored
@@ -1,80 +0,0 @@
|
||||
/* 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']
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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',
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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
5673
hertz_server_django_ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "hertz_server_django_ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"prune": "node scripts/prune-modules.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"ant-design-vue": "^3.2.20",
|
||||
"axios": "^1.12.2",
|
||||
"echarts": "^6.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"onnxruntime-web": "^1.23.2",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"sass-embedded": "^1.93.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"unplugin-vue-components": "^29.1.0",
|
||||
"vite": "^7.1.6",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,392 +0,0 @@
|
||||
#!/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)
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
<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>
|
||||
@@ -1,96 +0,0 @@
|
||||
import { request } from '@/utils/hertz_request'
|
||||
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 会话与消息类型
|
||||
export interface AIChatItem {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
latest_message?: string
|
||||
}
|
||||
|
||||
export interface AIChatDetail {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AIChatMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatListData {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
chats: AIChatItem[]
|
||||
}
|
||||
|
||||
export interface ChatDetailData {
|
||||
chat: AIChatDetail
|
||||
messages: AIChatMessage[]
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
user_message: AIChatMessage
|
||||
ai_message: AIChatMessage
|
||||
}
|
||||
|
||||
// 将后端可能返回的 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),
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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/')
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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/')
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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'
|
||||
@@ -1,131 +0,0 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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/`)
|
||||
},
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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 } })
|
||||
},
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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 }),
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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/')
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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/'),
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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/')
|
||||
}
|
||||
}
|
||||
@@ -1,643 +0,0 @@
|
||||
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
|
||||
@@ -1,85 +0,0 @@
|
||||
export type HertzModuleGroup = 'admin' | 'user'
|
||||
|
||||
export interface HertzModule {
|
||||
key: string
|
||||
label: string
|
||||
group: HertzModuleGroup
|
||||
description?: string
|
||||
defaultEnabled: boolean
|
||||
}
|
||||
|
||||
export const HERTZ_MODULES: HertzModule[] = [
|
||||
{ key: 'admin.user-management', label: '管理端 · 用户管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.department-management', label: '管理端 · 部门管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.menu-management', label: '管理端 · 菜单管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.role-management', label: '管理端 · 角色管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.notification-management', label: '管理端 · 通知管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.log-management', label: '管理端 · 日志管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.knowledge-base', label: '管理端 · 文章管理', group: 'admin', defaultEnabled: true },
|
||||
{ key: 'admin.yolo-model', label: '管理端 · YOLO 模型相关', group: 'admin', defaultEnabled: true },
|
||||
|
||||
{ key: 'user.system-monitor', label: '用户端 · 系统监控', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.ai-chat', label: '用户端 · AI 助手', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.yolo-detection', label: '用户端 · YOLO 检测', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.live-detection', label: '用户端 · 实时检测', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.detection-history', label: '用户端 · 检测历史', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.alert-center', label: '用户端 · 告警中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.notice-center', label: '用户端 · 通知中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.knowledge-center', label: '用户端 · 文章中心', group: 'user', defaultEnabled: true },
|
||||
{ key: 'user.kb-center', label: '用户端 · 知识库中心', group: 'user', defaultEnabled: true },
|
||||
]
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'hertz_enabled_modules'
|
||||
|
||||
export function getEnabledModuleKeys(): string[] {
|
||||
const fallback = HERTZ_MODULES.filter(m => m.defaultEnabled).map(m => m.key)
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY)
|
||||
if (!stored) return fallback
|
||||
const parsed = JSON.parse(stored)
|
||||
if (Array.isArray(parsed)) {
|
||||
const valid = parsed.filter((k): k is string => typeof k === 'string')
|
||||
// 自动合并新增的默认启用模块,避免新模块在已有选择下被永久隐藏
|
||||
const missingDefaults = HERTZ_MODULES
|
||||
.filter(m => m.defaultEnabled && !valid.includes(m.key))
|
||||
.map(m => m.key)
|
||||
return [...valid, ...missingDefaults]
|
||||
}
|
||||
return fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setEnabledModuleKeys(keys: string[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(keys))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function isModuleEnabled(moduleKey?: string, enabledKeys?: string[]): boolean {
|
||||
if (!moduleKey) return true
|
||||
const keys = enabledKeys ?? getEnabledModuleKeys()
|
||||
return keys.indexOf(moduleKey) !== -1
|
||||
}
|
||||
|
||||
export function getModulesByGroup(group: HertzModuleGroup): HertzModule[] {
|
||||
return HERTZ_MODULES.filter(m => m.group === group)
|
||||
}
|
||||
|
||||
export function hasModuleSelection(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return window.localStorage.getItem(LOCAL_STORAGE_KEY) !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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',
|
||||
},
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -1,172 +0,0 @@
|
||||
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: '系统配置错误,请联系管理员',
|
||||
},
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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')
|
||||
@@ -1,459 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,295 +0,0 @@
|
||||
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;
|
||||
@@ -1,194 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -1,422 +0,0 @@
|
||||
// 全局样式入口文件
|
||||
@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; }
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// 全局变量文件 - 简约现代风格
|
||||
|
||||
// 颜色系统
|
||||
$primary-color: #2563eb;
|
||||
$primary-light: #3b82f6;
|
||||
$primary-dark: #1d4ed8;
|
||||
$success-color: #10b981;
|
||||
$warning-color: #f59e0b;
|
||||
$error-color: #ef4444;
|
||||
$info-color: #06b6d4;
|
||||
|
||||
// 中性色系统
|
||||
$gray-50: #f9fafb;
|
||||
$gray-100: #f3f4f6;
|
||||
$gray-200: #e5e7eb;
|
||||
$gray-300: #d1d5db;
|
||||
$gray-400: #9ca3af;
|
||||
$gray-500: #6b7280;
|
||||
$gray-600: #4b5563;
|
||||
$gray-700: #374151;
|
||||
$gray-800: #1f2937;
|
||||
$gray-900: #111827;
|
||||
|
||||
// 背景色
|
||||
$bg-primary: #ffffff;
|
||||
$bg-secondary: #f9fafb;
|
||||
$bg-tertiary: #f3f4f6;
|
||||
|
||||
// 字体大小
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 14px;
|
||||
$font-size-base: 16px;
|
||||
$font-size-lg: 18px;
|
||||
$font-size-xl: 20px;
|
||||
$font-size-2xl: 24px;
|
||||
$font-size-3xl: 30px;
|
||||
$font-size-4xl: 36px;
|
||||
|
||||
// 间距系统 - 4px基础单位
|
||||
$spacing-0: 0;
|
||||
$spacing-1: 4px;
|
||||
$spacing-2: 8px;
|
||||
$spacing-3: 12px;
|
||||
$spacing-4: 16px;
|
||||
$spacing-5: 20px;
|
||||
$spacing-6: 24px;
|
||||
$spacing-8: 32px;
|
||||
$spacing-10: 40px;
|
||||
$spacing-12: 48px;
|
||||
$spacing-16: 64px;
|
||||
$spacing-20: 80px;
|
||||
|
||||
// 圆角系统
|
||||
$radius-none: 0;
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 6px;
|
||||
$radius-lg: 8px;
|
||||
$radius-xl: 12px;
|
||||
$radius-2xl: 16px;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// 阴影系统
|
||||
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
// 过渡时间
|
||||
$transition-fast: 0.15s;
|
||||
$transition-normal: 0.2s;
|
||||
$transition-slow: 0.3s;
|
||||
|
||||
// 混合器
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@mixin text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin box-shadow($shadow: $shadow-md) {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
@mixin transition($property: all, $duration: $transition-normal) {
|
||||
transition: #{$property} #{$duration} ease;
|
||||
}
|
||||
|
||||
@mixin card-style {
|
||||
background: $bg-primary;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1px solid $gray-200;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-style($bg-color: $primary-color, $text-color: white) {
|
||||
background: $bg-color;
|
||||
color: $text-color;
|
||||
border: 1px solid $bg-color;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-3 $spacing-6;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: darken($bg-color, 8%);
|
||||
border-color: darken($bg-color, 8%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: darken($bg-color, 12%);
|
||||
}
|
||||
}
|
||||
13
hertz_server_django_ui/src/types/env.d.ts
vendored
13
hertz_server_django_ui/src/types/env.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
/// <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
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// 通用响应类型
|
||||
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
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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 }
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 环境变量检查工具
|
||||
* 用于在开发环境中检查环境变量配置是否正确
|
||||
*/
|
||||
|
||||
// 检查环境变量配置
|
||||
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',
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* 权限管理工具类
|
||||
* 统一管理用户权限检查和菜单过滤逻辑
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
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 }
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 路由工具函数
|
||||
* 用于动态路由相关的辅助功能
|
||||
*/
|
||||
|
||||
// 获取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('💡 提示: 启动项目后会在控制台看到真正的动态路由信息')
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,730 +0,0 @@
|
||||
// 前端ONNX YOLO检测工具类
|
||||
import * as ort from 'onnxruntime-web'
|
||||
|
||||
// ONNX检测结果接口
|
||||
export interface YOLODetectionResult {
|
||||
detections: Array<{
|
||||
class_name: string
|
||||
confidence: number
|
||||
bbox: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}>
|
||||
object_count: number
|
||||
detected_categories: string[]
|
||||
confidence_scores: number[]
|
||||
avg_confidence: number
|
||||
annotated_image: string // base64图像
|
||||
processing_time: number
|
||||
}
|
||||
|
||||
// 不预置任何类别名称;等待后端或标签文件提供
|
||||
|
||||
class YOLODetector {
|
||||
private session: ort.InferenceSession | null = null
|
||||
private modelPath: string = ''
|
||||
private classNames: string[] = []
|
||||
private inputShape: [number, number] = [640, 640] // 默认输入尺寸(可在 WASM 下动态调小)
|
||||
private currentEP: 'webgpu' | 'webgl' | 'wasm' = 'wasm'
|
||||
|
||||
/**
|
||||
* 加载ONNX模型
|
||||
* @param modelPath 模型路径(相对于public目录)
|
||||
* @param classNames 类别名称列表(可选,如果不提供则使用默认COCO类别)
|
||||
*/
|
||||
async loadModel(modelPath: string, classNames?: string[], forceEP?: 'webgpu' | 'webgl' | 'wasm'): Promise<void> {
|
||||
try {
|
||||
console.log('🔄 开始加载ONNX模型:', modelPath)
|
||||
|
||||
// 设置类别名称
|
||||
if (classNames && classNames.length > 0) {
|
||||
this.classNames = classNames
|
||||
console.log('📦 使用自定义类别:', classNames.length, '个类别')
|
||||
} else {
|
||||
// 如果未提供类别,稍后根据输出维度自动推断数量并用 class_0.. 命名
|
||||
this.classNames = []
|
||||
console.log('📦 未提供类别,将根据模型输出自动推断类别数量')
|
||||
}
|
||||
|
||||
// 动态选择可用的 wasm 资源路径,避免 404/HTML 导致的“magic word”错误
|
||||
const ensureWasmPath = async () => {
|
||||
const candidates = [
|
||||
'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/',
|
||||
'https://unpkg.com/onnxruntime-web@1.23.2/dist/',
|
||||
'/onnxruntime-web/', // 如果你把 dist 拷贝到 public/onnxruntime-web/
|
||||
'/ort/' // 或者 public/ort/
|
||||
]
|
||||
for (const base of candidates) {
|
||||
try {
|
||||
const testUrl = base.replace(/\/$/, '') + '/ort-wasm.wasm'
|
||||
const res = await fetch(testUrl, { method: 'HEAD', cache: 'no-store', mode: 'no-cors' as any })
|
||||
// no-cors 模式下 status 为 0,也视为可用(跨域但可下载)
|
||||
if (res.ok || res.status === 0) {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.wasmPaths = base
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
await ensureWasmPath()
|
||||
|
||||
// 配置 WASM 线程:若不支持跨域隔离/SharedArrayBuffer,则退回单线程,避免“worker not ready”
|
||||
const canMultiThread = (self as any).crossOriginIsolated && typeof (self as any).SharedArrayBuffer !== 'undefined'
|
||||
try {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = canMultiThread ? Math.max(2, Math.min(4, (navigator as any)?.hardwareConcurrency || 2)) : 1
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
} catch {}
|
||||
|
||||
const createWithEP = async (ep: 'webgpu' | 'webgl' | 'wasm') => {
|
||||
if (ep === 'webgpu') {
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['webgpu'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'webgpu'
|
||||
return
|
||||
}
|
||||
if (ep === 'webgl') {
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['webgl'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'webgl'
|
||||
return
|
||||
}
|
||||
// wasm
|
||||
const prefer: ort.InferenceSession.SessionOptions = {
|
||||
executionProviders: ['wasm'],
|
||||
graphOptimizationLevel: 'all',
|
||||
}
|
||||
this.session = await ort.InferenceSession.create(modelPath, prefer)
|
||||
this.currentEP = 'wasm'
|
||||
}
|
||||
|
||||
// 配置 ONNX Runtime:优先 GPU(WebGPU/WebGL),再回退 WASM
|
||||
// 支持通过 localStorage 开关强制使用 WASM:localStorage.setItem('ort_force_wasm','1')
|
||||
// 也可通过第三个参数 forceEP 指定(用于错误时的程序化降级)
|
||||
const forceWasm = forceEP === 'wasm' || (localStorage.getItem('ort_force_wasm') === '1')
|
||||
// 1) WebGPU(实验性,浏览器需支持 navigator.gpu)
|
||||
let created = false
|
||||
if (!forceWasm && (navigator as any)?.gpu && (!forceEP || forceEP === 'webgpu')) {
|
||||
try {
|
||||
// 动态引入 webgpu 版本(若不支持不会打包)
|
||||
await import('onnxruntime-web/webgpu')
|
||||
await createWithEP('webgpu')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGPU 推理')
|
||||
} catch (e) {
|
||||
console.warn('⚠️ WebGPU 初始化失败,回退到 WebGL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) WebGL(GPU 加速,兼容更好)
|
||||
if (!forceWasm && !created && (!forceEP || forceEP === 'webgl')) {
|
||||
try {
|
||||
await createWithEP('webgl')
|
||||
created = true
|
||||
console.log('✅ 使用 WebGL 推理')
|
||||
} catch (e2) {
|
||||
console.warn('⚠️ WebGL 初始化失败,回退到 WASM:', e2)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) WASM(CPU)
|
||||
if (!created) {
|
||||
try {
|
||||
// 设置 WASM 线程/特性(路径已在 ensureWasmPath 中选择)
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = Math.max(1, Math.min(4, (navigator as any)?.hardwareConcurrency || 2))
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
} catch {}
|
||||
|
||||
await createWithEP('wasm')
|
||||
console.log('✅ 使用 WASM 推理')
|
||||
}
|
||||
this.modelPath = modelPath
|
||||
|
||||
// 根据后端动态调整输入尺寸:WASM 默认调小以提升流畅度,可用 localStorage 覆盖
|
||||
try {
|
||||
const override = parseInt(localStorage.getItem('ort_input_size') || '', 10)
|
||||
if (Number.isFinite(override) && override >= 256 && override <= 1024) {
|
||||
this.inputShape = [override, override] as any
|
||||
} else if (this.currentEP === 'wasm') {
|
||||
this.inputShape = [512, 512] as any
|
||||
} else {
|
||||
this.inputShape = [640, 640] as any
|
||||
}
|
||||
console.log('🧩 推理输入尺寸:', this.inputShape[0])
|
||||
} catch {}
|
||||
|
||||
// 获取模型输入输出信息(兼容性更强的写法)
|
||||
const inputNames = this.session.inputNames
|
||||
const outputNames = this.session.outputNames
|
||||
console.log('✅ 模型加载成功')
|
||||
console.log('📥 输入:', inputNames)
|
||||
console.log('📤 输出:', outputNames)
|
||||
|
||||
// 尝试从 outputMetadata 推断类别数(某些环境不提供 dims,需要兜底)
|
||||
try {
|
||||
if (outputNames && outputNames.length > 0) {
|
||||
const outputMetadata: any = (this.session as any).outputMetadata
|
||||
const outputName = outputNames[0]
|
||||
const meta = outputMetadata?.[outputName]
|
||||
const outputShape: number[] | undefined = meta?.dims
|
||||
if (Array.isArray(outputShape) && outputShape.length >= 3) {
|
||||
const numClasses = (outputShape[2] as number) - 5 // YOLO: [N, B, 5+C]
|
||||
if (Number.isFinite(numClasses) && numClasses > 0 && numClasses !== this.classNames.length) {
|
||||
console.warn(`⚠️ 模型输出类别数 (${numClasses}) 与提供的类别数 (${this.classNames.length}) 不匹配/或未提供`)
|
||||
if (this.classNames.length === 0) {
|
||||
this.classNames = Array.from({ length: numClasses }, (_, i) => `class_${i}`)
|
||||
console.log('📦 根据模型输出调整类别数量为:', numClasses)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 无法从 outputMetadata 推断输出维度,将在首次推理时根据输出tensor推断。')
|
||||
}
|
||||
}
|
||||
} catch (metaErr) {
|
||||
console.warn('⚠️ 读取 outputMetadata 失败,将在首次推理时推断类别数。', metaErr)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 加载模型失败:', error)
|
||||
throw new Error(`加载ONNX模型失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否已加载
|
||||
*/
|
||||
isLoaded(): boolean {
|
||||
return this.session !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的模型路径
|
||||
*/
|
||||
getModelPath(): string {
|
||||
return this.modelPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类别名称列表
|
||||
*/
|
||||
getClassNames(): string[] {
|
||||
return this.classNames
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理图像(Ultralytics letterbox:保比例缩放+灰边填充)
|
||||
* 返回输入张量与还原坐标所需的比例与padding
|
||||
*/
|
||||
private preprocessImage(image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement): {
|
||||
input: Float32Array
|
||||
ratio: number
|
||||
padX: number
|
||||
padY: number
|
||||
dstW: number
|
||||
dstH: number
|
||||
srcW: number
|
||||
srcH: number
|
||||
} {
|
||||
const dstW = this.inputShape[0]
|
||||
const dstH = this.inputShape[1]
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('无法创建canvas上下文')
|
||||
|
||||
const srcW = image instanceof HTMLVideoElement ? image.videoWidth : (image as HTMLImageElement | HTMLCanvasElement).width
|
||||
const srcH = image instanceof HTMLVideoElement ? image.videoHeight : (image as HTMLImageElement | HTMLCanvasElement).height
|
||||
|
||||
// 计算 letterbox
|
||||
const r = Math.min(dstW / srcW, dstH / srcH)
|
||||
const newW = Math.round(srcW * r)
|
||||
const newH = Math.round(srcH * r)
|
||||
const padX = Math.floor((dstW - newW) / 2)
|
||||
const padY = Math.floor((dstH - newH) / 2)
|
||||
|
||||
canvas.width = dstW
|
||||
canvas.height = dstH
|
||||
// 背景填充灰色(114)与 Ultralytics 一致
|
||||
ctx.fillStyle = 'rgb(114,114,114)'
|
||||
ctx.fillRect(0, 0, dstW, dstH)
|
||||
// 绘制等比缩放后的图像到中间
|
||||
ctx.drawImage(image as any, 0, 0, srcW, srcH, padX, padY, newW, newH)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, dstW, dstH)
|
||||
const data = imageData.data
|
||||
|
||||
const input = new Float32Array(3 * dstW * dstH)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r8 = data[i] / 255.0
|
||||
const g8 = data[i + 1] / 255.0
|
||||
const b8 = data[i + 2] / 255.0
|
||||
const idx = i / 4
|
||||
input[idx] = r8
|
||||
input[idx + dstW * dstH] = g8
|
||||
input[idx + dstW * dstH * 2] = b8
|
||||
}
|
||||
|
||||
return { input, ratio: r, padX, padY, dstW, dstH, srcW, srcH }
|
||||
}
|
||||
|
||||
/**
|
||||
* 非极大值抑制(NMS)
|
||||
*/
|
||||
private nms(boxes: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}>, iouThreshold: number): number[] {
|
||||
if (boxes.length === 0) return []
|
||||
|
||||
// 按置信度排序
|
||||
boxes.sort((a, b) => b.conf - a.conf)
|
||||
|
||||
const selected: number[] = []
|
||||
const suppressed = new Set<number>()
|
||||
|
||||
for (let i = 0; i <boxes.length; i++) {
|
||||
if (suppressed.has(i)) continue
|
||||
|
||||
selected.push(i)
|
||||
const box1 = boxes[i]
|
||||
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
if (suppressed.has(j)) continue
|
||||
|
||||
const box2 = boxes[j]
|
||||
|
||||
// 计算IoU
|
||||
const iou = this.calculateIoU(box1, box2)
|
||||
|
||||
if (iou > iouThreshold) {
|
||||
suppressed.add(j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算IoU(交并比)
|
||||
*/
|
||||
private calculateIoU(box1: {x: number, y: number, w: number, h: number}, box2: {x: number, y: number, w: number, h: number}): number {
|
||||
const x1 = Math.max(box1.x, box2.x)
|
||||
const y1 = Math.max(box1.y, box2.y)
|
||||
const x2 = Math.min(box1.x + box1.w, box2.x + box2.w)
|
||||
const y2 = Math.min(box1.y + box1.h, box2.y + box2.h)
|
||||
|
||||
if (x2 < x1 || y2 < y1) return 0
|
||||
|
||||
const intersection = (x2 - x1) * (y2 - y1)
|
||||
const area1 = box1.w * box1.h
|
||||
const area2 = box2.w * box2.h
|
||||
const union = area1 + area2 - intersection
|
||||
|
||||
return intersection / union
|
||||
}
|
||||
|
||||
/**
|
||||
* 后处理检测结果
|
||||
*/
|
||||
private postprocess(
|
||||
output: ort.Tensor,
|
||||
meta: { ratio: number; padX: number; padY: number; srcW: number; srcH: number },
|
||||
confThreshold: number,
|
||||
nmsThreshold: number,
|
||||
opts?: { maxDetections?: number; minBoxArea?: number; classWise?: boolean }
|
||||
): Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}> {
|
||||
const outputData = output.data as Float32Array
|
||||
const outputShape = output.dims || []
|
||||
|
||||
// YOLO输出常见两种:
|
||||
// A) [1, num_boxes, 5+num_classes]
|
||||
// B) [1, 5+num_classes, num_boxes]
|
||||
// 另外也可能已经扁平化为 [num_boxes, 5+num_classes]
|
||||
let numBoxes = 0
|
||||
let numFeatures = 0
|
||||
if (outputShape.length === 3) {
|
||||
// 取更大的作为 boxes 维度(通常是 8400),较小的是 5+C(通常是 85)
|
||||
const a = outputShape[1] as number
|
||||
const b = outputShape[2] as number
|
||||
if (a >= b) {
|
||||
numBoxes = a
|
||||
numFeatures = b
|
||||
} else {
|
||||
numBoxes = b
|
||||
numFeatures = a
|
||||
}
|
||||
} else if (outputShape.length === 2) {
|
||||
numBoxes = outputShape[0] as number
|
||||
numFeatures = outputShape[1] as number
|
||||
} else {
|
||||
// 无维度信息时根据长度推断(保底)
|
||||
numFeatures = 85
|
||||
numBoxes = Math.floor(outputData.length / numFeatures)
|
||||
}
|
||||
const numClasses = Math.max(0, numFeatures - 5)
|
||||
|
||||
const detections: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
|
||||
|
||||
// 还原到原图坐标:先减去 padding,再除以 ratio
|
||||
const { ratio, padX, padY, srcW: originalWidth, srcH: originalHeight } = meta
|
||||
|
||||
// 获取 (row i, col j) 的值,兼容布局 A/B
|
||||
const getVal = (i: number, j: number): number => {
|
||||
if (outputShape.length === 3) {
|
||||
const a = outputShape[1] as number
|
||||
const b = outputShape[2] as number
|
||||
if (a >= b) {
|
||||
// [1, boxes, features]
|
||||
return outputData[i * b + j]
|
||||
}
|
||||
// [1, features, boxes]
|
||||
return outputData[j * a + i]
|
||||
}
|
||||
// [boxes, features]
|
||||
return outputData[i * numFeatures + j]
|
||||
}
|
||||
|
||||
// sigmoid
|
||||
const sigmoid = (v: number) => 1 / (1 + Math.exp(-v))
|
||||
|
||||
// 情况一:部分导出的ONNX已经做过NMS,输出形如 [num, 6]:x1,y1,x2,y2,score,classId(或其它顺序)。
|
||||
const tryPostNmsLayouts = () => {
|
||||
const candidates: Array<(row: (j:number)=>number) => {x:number,y:number,w:number,h:number,conf:number,cls:number} | null> = [
|
||||
// [x1,y1,x2,y2,score,cls]
|
||||
(get) => {
|
||||
const x1 = get(0), y1 = get(1), x2 = get(2), y2 = get(3)
|
||||
const score = get(4), cls = get(5)
|
||||
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
|
||||
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
},
|
||||
// [cls,score,x1,y1,x2,y2]
|
||||
(get) => {
|
||||
const cls = get(0), score = get(1), x1 = get(2), y1 = get(3), x2 = get(4), y2 = get(5)
|
||||
if (!isFinite(x1+y1+x2+y2+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
|
||||
return { x: Math.min(x1,x2), y: Math.min(y1,y2), w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
},
|
||||
// [x,y,w,h,score,cls](xywh)
|
||||
(get) => {
|
||||
const x = get(0), y = get(1), w = get(2), h = get(3), score = get(4), cls = get(5)
|
||||
if (!isFinite(x+y+w+h+score+cls)) return null
|
||||
if (score < 0 || score > 1) return null
|
||||
return { x: x - w/2, y: y - h/2, w, h, conf: score, cls: Math.max(0, Math.floor(cls)) }
|
||||
}
|
||||
]
|
||||
const out: typeof detections = []
|
||||
for (let i = 0; i < numBoxes; i++) {
|
||||
const getter = (j:number) => getVal(i, j)
|
||||
let picked = null
|
||||
for (const decode of candidates) {
|
||||
picked = decode(getter)
|
||||
if (picked && picked.conf >= confThreshold) break
|
||||
}
|
||||
if (!picked || picked.conf < confThreshold) continue
|
||||
// 还原坐标
|
||||
let { x, y, w, h, conf, cls } = picked
|
||||
x = (x - padX) / ratio
|
||||
y = (y - padY) / ratio
|
||||
w = w / ratio
|
||||
h = h / ratio
|
||||
const area = Math.max(0, w) * Math.max(0, h)
|
||||
const minArea = opts?.minBoxArea ?? (meta.srcW * meta.srcH * 0.0001)
|
||||
if (area <= 0 || area < minArea) continue
|
||||
out.push({ x, y, w, h, conf, class: cls })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 情况二:原始预测 [*, *, 5+num_classes],需要 obj × class 计算。
|
||||
// 支持两种坐标格式:xywh(中心点) 与 xyxy(左上/右下)。优先取能得到更多有效框的解码。
|
||||
const decode = (mode: 'xywh' | 'xyxy') => {
|
||||
const out: typeof detections = []
|
||||
for (let i = 0; i < numBoxes; i++) {
|
||||
const v0 = getVal(i, 0)
|
||||
const v1 = getVal(i, 1)
|
||||
const v2 = getVal(i, 2)
|
||||
const v3 = getVal(i, 3)
|
||||
const objConf = sigmoid(getVal(i, 4))
|
||||
|
||||
// 最大类别
|
||||
let maxClassConf = 0
|
||||
let maxClassIdx = 0
|
||||
for (let j = 0; j < numClasses; j++) {
|
||||
const classConf = sigmoid(getVal(i, 5 + j))
|
||||
if (classConf > maxClassConf) {
|
||||
maxClassConf = classConf
|
||||
maxClassIdx = j
|
||||
}
|
||||
}
|
||||
const confidence = objConf * maxClassConf
|
||||
if (confidence < confThreshold) continue
|
||||
|
||||
let x = 0, y = 0, w = 0, h = 0
|
||||
if (mode === 'xywh') {
|
||||
const xc = (v0 - padX) / ratio
|
||||
const yc = (v1 - padY) / ratio
|
||||
const wv = v2 / ratio
|
||||
const hv = v3 / ratio
|
||||
x = xc - wv / 2
|
||||
y = yc - hv / 2
|
||||
w = wv
|
||||
h = hv
|
||||
} else {
|
||||
// xyxy
|
||||
const x1 = (v0 - padX) / ratio
|
||||
const y1 = (v1 - padY) / ratio
|
||||
const x2 = (v2 - padX) / ratio
|
||||
const y2 = (v3 - padY) / ratio
|
||||
x = Math.min(x1, x2)
|
||||
y = Math.min(y1, y2)
|
||||
w = Math.abs(x2 - x1)
|
||||
h = Math.abs(y2 - y1)
|
||||
}
|
||||
const area = Math.max(0, w) * Math.max(0, h)
|
||||
const minArea = opts?.minBoxArea ?? (originalWidth * originalHeight * 0.00005) // 放宽:0.005%
|
||||
if (area <= 0 || area < minArea) continue
|
||||
if (w > 4 * originalWidth || h > 4 * originalHeight) continue // 明显异常
|
||||
|
||||
out.push({ x, y, w, h, conf: confidence, class: maxClassIdx })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let pick: typeof detections = []
|
||||
// 若特征维很小(<=6),优先按“已NMS格式”解析
|
||||
if (numFeatures <= 6) {
|
||||
pick = tryPostNmsLayouts()
|
||||
}
|
||||
// 否则按原始格式解码
|
||||
if (pick.length === 0) {
|
||||
const d1 = decode('xywh')
|
||||
const d2 = decode('xyxy')
|
||||
pick = d2.length > d1.length ? d2 : d1
|
||||
}
|
||||
detections.push(...pick)
|
||||
// 执行NMS(支持按类别)
|
||||
const classWise = opts?.classWise ?? true
|
||||
let kept: Array<{x: number, y: number, w: number, h: number, conf: number, class: number}> = []
|
||||
if (classWise) {
|
||||
const byClass: Record<number, typeof detections> = {}
|
||||
for (const d of detections) {
|
||||
(byClass[d.class] ||= []).push(d)
|
||||
}
|
||||
for (const k in byClass) {
|
||||
const group = byClass[k]
|
||||
const idxs = this.nms(group, nmsThreshold)
|
||||
kept.push(...idxs.map(i => group[i]))
|
||||
}
|
||||
} else {
|
||||
const idxs = this.nms(detections, nmsThreshold)
|
||||
kept = idxs.map(i => detections[i])
|
||||
}
|
||||
|
||||
// 置信度排序并限制最大数量
|
||||
kept.sort((a, b) => b.conf - a.conf)
|
||||
const limited = kept.slice(0, opts?.maxDetections ?? 100)
|
||||
|
||||
// 构建最终结果
|
||||
return limited.map(det => {
|
||||
const className = this.classNames[det.class] || `class_${det.class}`
|
||||
return {
|
||||
class_name: className,
|
||||
confidence: det.conf,
|
||||
bbox: {
|
||||
x: Math.max(0, det.x),
|
||||
y: Math.max(0, det.y),
|
||||
width: Math.min(det.w, originalWidth - det.x),
|
||||
height: Math.min(det.h, originalHeight - det.y)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图像上绘制检测框
|
||||
*/
|
||||
private drawDetections(canvas: HTMLCanvasElement, detections: Array<{class_name: string, confidence: number, bbox: {x: number, y: number, width: number, height: number}}>): void {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 为每个类别分配颜色
|
||||
const colors: {[key: string]: string} = {}
|
||||
const colorPalette = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
|
||||
|
||||
detections.forEach((det, idx) => {
|
||||
if (!colors[det.class_name]) {
|
||||
colors[det.class_name] = colorPalette[idx % colorPalette.length]
|
||||
}
|
||||
})
|
||||
|
||||
detections.forEach(det => {
|
||||
const { x, y, width, height } = det.bbox
|
||||
const color = colors[det.class_name]
|
||||
|
||||
// 绘制边界框
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(x, y, width, height)
|
||||
|
||||
// 绘制标签背景
|
||||
const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`
|
||||
ctx.font = '14px Arial'
|
||||
const textMetrics = ctx.measureText(label)
|
||||
const textWidth = textMetrics.width
|
||||
const textHeight = 20
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y - textHeight, textWidth + 10, textHeight)
|
||||
|
||||
// 绘制标签文字
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.fillText(label, x + 5, y - 5)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行检测
|
||||
* @param image 图像元素(Image, Video, 或 Canvas)
|
||||
* @param confidenceThreshold 置信度阈值
|
||||
* @param nmsThreshold NMS阈值
|
||||
*/
|
||||
async detect(
|
||||
image: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement,
|
||||
confidenceThreshold: number = 0.25,
|
||||
nmsThreshold: number = 0.7
|
||||
): Promise<YOLODetectionResult> {
|
||||
if (!this.session) {
|
||||
throw new Error('模型未加载,请先调用 loadModel()')
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
// 获取原始图像尺寸
|
||||
const originalWidth = image instanceof HTMLVideoElement ? image.videoWidth : image.width
|
||||
const originalHeight = image instanceof HTMLVideoElement ? image.videoHeight : image.height
|
||||
|
||||
// 预处理图像(letterbox)
|
||||
const prep = this.preprocessImage(image)
|
||||
|
||||
// 创建输入tensor [1, 3, H, W]
|
||||
const inputTensor = new ort.Tensor('float32', prep.input, [1, 3, this.inputShape[1], this.inputShape[0]])
|
||||
|
||||
// 执行推理
|
||||
const inputName = this.session.inputNames[0]
|
||||
const feeds = { [inputName]: inputTensor }
|
||||
const results = await this.session.run(feeds)
|
||||
|
||||
// 获取输出
|
||||
const outputName = this.session.outputNames[0]
|
||||
const output = results[outputName]
|
||||
|
||||
// 后处理
|
||||
const detections = this.postprocess(
|
||||
output,
|
||||
{ ratio: prep.ratio, padX: prep.padX, padY: prep.padY, srcW: originalWidth, srcH: originalHeight },
|
||||
confidenceThreshold,
|
||||
nmsThreshold,
|
||||
{ maxDetections: 100, minBoxArea: originalWidth * originalHeight * 0.0001, classWise: true }
|
||||
)
|
||||
|
||||
// 计算统计信息
|
||||
const objectCount = detections.length
|
||||
const detectedCategories = [...new Set(detections.map(d => d.class_name))]
|
||||
const confidenceScores = detections.map(d => d.confidence)
|
||||
const avgConfidence = confidenceScores.length > 0
|
||||
? confidenceScores.reduce((a, b) => a + b, 0) / confidenceScores.length
|
||||
: 0
|
||||
|
||||
// 绘制检测结果
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = originalWidth
|
||||
canvas.height = originalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0, originalWidth, originalHeight)
|
||||
this.drawDetections(canvas, detections)
|
||||
}
|
||||
|
||||
// 转换为base64(降低质量,减少内存与传输开销)
|
||||
const annotatedImage = canvas.toDataURL('image/jpeg', 0.4)
|
||||
|
||||
const processingTime = (performance.now() - startTime) / 1000
|
||||
|
||||
return {
|
||||
detections: detections.map(d => ({
|
||||
class_name: d.class_name,
|
||||
confidence: d.confidence,
|
||||
bbox: d.bbox
|
||||
})),
|
||||
object_count: objectCount,
|
||||
detected_categories: detectedCategories,
|
||||
confidence_scores: confidenceScores,
|
||||
avg_confidence: avgConfidence,
|
||||
annotated_image: annotatedImage,
|
||||
processing_time: processingTime
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 检测失败:', error)
|
||||
// 若 GPU 后端不支持某些算子,自动回退到 WASM 并重试一次
|
||||
const msg = String((error as any)?.message || error)
|
||||
const needFallback = /GatherND|Unsupported data type|JSF Kernel|ExecuteKernel|WebGPU|WebGL|worker not ready/i.test(msg)
|
||||
if (needFallback && this.currentEP !== 'wasm') {
|
||||
try {
|
||||
console.warn('⚠️ 检测算子不被 GPU 支持,自动回退到 WASM 并重试一次。')
|
||||
// 强制全局与本次调用走 WASM
|
||||
localStorage.setItem('ort_force_wasm','1')
|
||||
await this.loadModel(this.modelPath, this.classNames, 'wasm')
|
||||
// 强制使用 wasm
|
||||
// @ts-ignore
|
||||
ort.env.wasm.proxy = true
|
||||
this.currentEP = 'wasm'
|
||||
return await this.detect(image, confidenceThreshold, nmsThreshold)
|
||||
} catch (e2) {
|
||||
console.error('❌ 回退到 WASM 后仍失败:', e2)
|
||||
}
|
||||
}
|
||||
// 如果已是 wasm,但报 worker not ready,再降级为单线程重建 session
|
||||
if (/worker not ready/i.test(msg) && this.currentEP === 'wasm') {
|
||||
try {
|
||||
// @ts-ignore
|
||||
ort.env.wasm.numThreads = 1
|
||||
await this.loadModel(this.modelPath, this.classNames)
|
||||
return await this.detect(image, confidenceThreshold, nmsThreshold)
|
||||
} catch (e3) {
|
||||
console.error('❌ 降级单线程后仍失败:', e3)
|
||||
}
|
||||
}
|
||||
throw new Error(`检测失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放模型资源
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.session) {
|
||||
// ONNX Runtime会自动管理资源,但我们可以清理引用
|
||||
this.session = null
|
||||
this.modelPath = ''
|
||||
console.log('🗑️ 模型资源已释放')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const yoloDetector = new YOLODetector()
|
||||
@@ -1,505 +0,0 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<!-- 简约导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">
|
||||
<span class="brand-name">管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navigation">
|
||||
<a href="#home" class="nav-link">首页</a>
|
||||
<a href="#about" class="nav-link">关于</a>
|
||||
<a href="#service" class="nav-link">服务</a>
|
||||
<a href="#contact" class="nav-link">联系</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="btn-login" @click="goToLogin">
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 英雄区域 -->
|
||||
<section class="hero-section" id="home">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">现代化管理系统模板</h1>
|
||||
<p class="hero-description">
|
||||
简洁、高效、易用的后台管理系统解决方案
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn-primary" @click="goToLogin">
|
||||
开始使用
|
||||
</button>
|
||||
<button class="btn-secondary">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 特性区域 -->
|
||||
<section class="features-section" id="service">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">核心特性</h2>
|
||||
<p class="section-subtitle">提供全方位的功能支持</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3 class="feature-title">数据可视化</h3>
|
||||
<p class="feature-description">
|
||||
直观的图表展示,让数据一目了然
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<h3 class="feature-title">权限管理</h3>
|
||||
<p class="feature-description">
|
||||
完善的权限控制体系,保障系统安全
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3 class="feature-title">高性能</h3>
|
||||
<p class="feature-description">
|
||||
优化的架构设计,提供流畅的使用体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3 class="feature-title">响应式设计</h3>
|
||||
<p class="feature-description">
|
||||
完美适配各种设备,随时随地访问
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🎨</div>
|
||||
<h3 class="feature-title">界面美观</h3>
|
||||
<p class="feature-description">
|
||||
现代化的UI设计,提升用户体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔧</div>
|
||||
<h3 class="feature-title">易于扩展</h3>
|
||||
<p class="feature-description">
|
||||
模块化设计,轻松添加新功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 关于区域 -->
|
||||
<section class="about-section" id="about">
|
||||
<div class="container">
|
||||
<div class="about-content">
|
||||
<div class="about-text">
|
||||
<h2 class="about-title">关于系统</h2>
|
||||
<p class="about-description">
|
||||
这是一个现代化的后台管理系统模板,采用最新的技术栈构建,
|
||||
提供完善的功能模块和优雅的用户界面,帮助您快速搭建企业级应用。
|
||||
</p>
|
||||
<ul class="about-features">
|
||||
<li>基于 Vue 3 + TypeScript</li>
|
||||
<li>Ant Design Vue 组件库</li>
|
||||
<li>响应式布局设计</li>
|
||||
<li>完整的权限管理</li>
|
||||
<li>丰富的功能模块</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer" id="contact">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<h3 class="footer-title">管理系统模板</h3>
|
||||
<p class="footer-description">
|
||||
现代化的后台管理系统解决方案
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="link-group">
|
||||
<h4>快速链接</h4>
|
||||
<ul>
|
||||
<li><a href="#" @click="goToLogin">登录系统</a></li>
|
||||
<li><a href="#about">关于我们</a></li>
|
||||
<li><a href="#service">功能介绍</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="link-group">
|
||||
<h4>联系方式</h4>
|
||||
<ul>
|
||||
<li>邮箱:contact@example.com</li>
|
||||
<li>电话:+86 123-4567-8900</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2024 管理系统模板. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
// 导航栏
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
.logo-text {
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.nav-link {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.btn-login {
|
||||
padding: 8px 20px;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 英雄区域
|
||||
.hero-section {
|
||||
padding: 160px 24px 120px;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 48px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.btn-primary {
|
||||
padding: 14px 32px;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 14px 32px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
color: #374151;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 特性区域
|
||||
.features-section {
|
||||
padding: 80px 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: #ffffff;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 关于区域
|
||||
.about-section {
|
||||
padding: 80px 24px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.about-description {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.about-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding: 8px 0;
|
||||
color: #374151;
|
||||
font-size: 15px;
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚
|
||||
.footer {
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 64px 24px 32px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
.footer-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.footer-description {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
|
||||
.link-group {
|
||||
h4 {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
|
||||
a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,464 +0,0 @@
|
||||
<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>
|
||||
@@ -1,149 +0,0 @@
|
||||
<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>
|
||||
@@ -1,65 +0,0 @@
|
||||
<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
@@ -1,989 +0,0 @@
|
||||
<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
@@ -1,383 +0,0 @@
|
||||
<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
@@ -1,625 +0,0 @@
|
||||
<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>
|
||||
@@ -1,671 +0,0 @@
|
||||
<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
@@ -1,251 +0,0 @@
|
||||
<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
@@ -1,318 +0,0 @@
|
||||
<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>
|
||||
@@ -1,842 +0,0 @@
|
||||
<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>
|
||||
@@ -1,519 +0,0 @@
|
||||
<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>
|
||||
@@ -1,835 +0,0 @@
|
||||
<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
1
hertz_server_django_ui/src/vite-env.d.ts
vendored
1
hertz_server_django_ui/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,422 +0,0 @@
|
||||
# 前端样式 / 布局 / UI 修改操作指南
|
||||
|
||||
> 面向二次开发前端,告诉你:**改样式 / 改布局 / 改 UI 具体要动哪些文件、怎么改**。
|
||||
>
|
||||
> 项目技术栈:Vite + Vue 3 + TypeScript + Ant Design Vue + Pinia + Vue Router + SCSS
|
||||
|
||||
---
|
||||
|
||||
## 🧭 速查表
|
||||
|
||||
- **改全局颜色 / 按钮 / 弹窗风格**:看第 1 章「整体样式体系总览」(`src/styles/index.scss` + `src/styles/variables.scss`)
|
||||
- **改管理端整体布局(侧边栏、头部、内容区排版)**:看 2.1「管理端整体布局」(`src/views/admin_page/index.vue`)
|
||||
- **改用户端整体布局(顶部导航 + 内容容器)」**:看 2.2「用户端整体布局」(`src/views/user_pages/index.vue`)
|
||||
- **改 YOLO 检测排版 / 三种布局 / 卡片样式**:看第 3 章「YOLO 检测页面修改指南」(`src/views/user_pages/YoloDetection.vue`)
|
||||
- **改 AI 助手聊天布局**:看第 4 章「修改 AI 助手页面」(`src/views/user_pages/AiChat.vue`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体样式体系总览
|
||||
|
||||
### 1.1 全局样式入口
|
||||
|
||||
- 入口文件:`src/styles/index.scss`
|
||||
- 在 `src/main.ts` 中全局引入:
|
||||
- `import './styles/index.scss'`
|
||||
- 主要职责:
|
||||
- 重置 margin / padding / box-sizing
|
||||
- 全局字体、`html, body, #app` 基础样式
|
||||
- 自定义 `.btn` / `.card` 等通用类
|
||||
- 全局 Ant Design Vue 主题风格覆盖(如 `.ant-modal`, `.ant-btn` 等)
|
||||
|
||||
**如果你要改全局的按钮、弹窗、表单、输入框等基础风格:**
|
||||
|
||||
1. 打开 `src/styles/index.scss`
|
||||
2. 找对应的选择器:
|
||||
- 按钮:`.ant-btn` 下的几种状态(`&.ant-btn-default` / `&.ant-btn-primary` / `&.ant-btn-dangerous` 等)
|
||||
- 弹窗:`.ant-modal` 内的 `.ant-modal-content` / `.ant-modal-header` / `.ant-modal-footer`
|
||||
- 输入/选择等:`.ant-input`, `.ant-select-selector`, `.ant-input-number`, `.ant-picker` 等
|
||||
3. 直接在这里调整颜色、圆角、阴影、间距。
|
||||
4. 样式会作用于所有页面,无需在每个 `.vue` 里重复写。
|
||||
|
||||
> 建议:全局 Design System 统一改在这里,不要在业务页面里到处改 AntD 默认样式。
|
||||
|
||||
### 1.2 变量和混合(主题基础)
|
||||
|
||||
- 文件:`src/styles/variables.scss`
|
||||
- 主要内容:
|
||||
- 颜色:`$primary-color`、`$success-color`、`$gray-xxx` 等
|
||||
- 间距:`$spacing-1 ~ $spacing-20`
|
||||
- 圆角:`$radius-md`、`$radius-lg` 等
|
||||
- 阴影:`$shadow-sm` / `$shadow-md` / `$shadow-lg`
|
||||
- 常用 mixin:`@mixin card-style`、`@mixin button-style` 等
|
||||
|
||||
**改全局配色 / 圆角 / 阴影的操作方式:**
|
||||
|
||||
1. 打开 `src/styles/variables.scss`
|
||||
2. 修改对应变量:
|
||||
- 主色:`$primary-color` / `$primary-light` / `$primary-dark`
|
||||
- 背景:`$bg-primary` / `$bg-secondary`
|
||||
- 阴影:`$shadow-md` / `$shadow-lg`
|
||||
3. 不需要修改业务页面,使用这些变量的地方会统一生效。
|
||||
|
||||
> 如果要在页面里复用统一卡片/按钮样式,可以直接:
|
||||
>
|
||||
> ```scss
|
||||
> .my-card {
|
||||
> @include card-style;
|
||||
> }
|
||||
>
|
||||
> .my-primary-button {
|
||||
> @include button-style($primary-color, #fff);
|
||||
> }
|
||||
> ```
|
||||
|
||||
### 1.3 主题 store 与 CSS 变量
|
||||
|
||||
- 文件:`src/stores/hertz_theme.ts`
|
||||
- 作用:
|
||||
- 定义 `ThemeConfig`(导航栏背景、页面背景、卡片背景、主色、文字颜色)
|
||||
- 使用 `document.documentElement.style.setProperty` 写入 CSS 变量:
|
||||
- `--theme-header-bg`, `--theme-page-bg`, `--theme-card-bg`, `--theme-primary`, `--theme-text-primary` 等
|
||||
- 使用方式:
|
||||
- 在页面/组件的 SCSS 中,通过 `var(--theme-primary)` 等变量引用主题色:
|
||||
- 示例:`color: var(--theme-text-primary, #1e293b);`
|
||||
|
||||
**修改主题默认值**:
|
||||
|
||||
1. 打开 `src/stores/hertz_theme.ts`
|
||||
2. 修改 `defaultTheme` 对象里的颜色值即可:
|
||||
- 如:`primaryColor: '#FF4D4F'` 改成你的品牌色
|
||||
3. 调用 `themeStore.loadTheme()` 时会自动应用到全局。
|
||||
|
||||
**页面内如何用这些主题变量?**
|
||||
|
||||
- 在 SCSS 中使用:
|
||||
|
||||
```scss
|
||||
.some-block {
|
||||
background: var(--theme-card-bg, #fff);
|
||||
color: var(--theme-text-primary, #1e293b);
|
||||
border-color: var(--theme-card-border, #e5e7eb);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 布局结构:管理端 / 用户端
|
||||
|
||||
### 2.1 管理端整体布局
|
||||
|
||||
- 入口布局:`src/views/admin_page/index.vue`
|
||||
- 结构:
|
||||
- 外层 `.admin-layout`
|
||||
- 使用 `a-layout` + `a-layout-sider` + `a-layout-header` + `a-layout-content` + `a-layout-footer`
|
||||
- 侧边菜单:`a-layout-sider` 内的 `a-menu`,使用 `admin_menu.ts` 生成菜单项
|
||||
|
||||
**修改管理端整体布局方式(比如侧边栏宽度、顶部高度):**
|
||||
|
||||
1. 打开 `src/views/admin_page/index.vue`
|
||||
2. 找到模板部分:
|
||||
- 侧边栏:`<a-layout-sider ... class="admin-sider">`
|
||||
- 顶部:`<a-layout-header class="header">`
|
||||
- 内容:`<a-layout-content class="content">`
|
||||
3. 在同文件底部的 `<style scoped lang="scss">` 中调整:
|
||||
- `.admin-layout`、`.admin-sider`、`.header`、`.content` 的 padding / background / shadow 等。
|
||||
4. 如果要改变菜单布局(比如改成顶部导航):需要同时更新 template 结构和对应的 SCSS。
|
||||
|
||||
### 2.2 用户端整体布局
|
||||
|
||||
- 入口布局:`src/views/user_pages/index.vue`
|
||||
- 结构:
|
||||
- 顶部 `a-layout-header.user-header`:包含 logo + 顶部菜单 + 面包屑 + 布局切换按钮 + 主题按钮 + 用户下拉
|
||||
- 主体 `a-layout.main-layout`,内部 `a-layout-content.user-content` 作为页面内容容器
|
||||
- 中间区域通过 `currentComponent` 动态切换不同业务页(YOLO 检测、AI 助手等)
|
||||
|
||||
**修改用户端整体布局(例如 header 高度、主内容宽度):**
|
||||
|
||||
1. 打开 `src/views/user_pages/index.vue`
|
||||
2. 在 template 里找到:
|
||||
- `<a-layout-header class="user-header">`
|
||||
- `<a-layout class="main-layout">`
|
||||
- `<a-layout-content class="user-content">`
|
||||
3. 在同文件的 `<style scoped lang="scss">` 中调整:
|
||||
- `.user-layout`、`.user-header`、`.main-layout`、`.user-content`、`.content-wrapper`
|
||||
4. 你也可以在这里添加全局背景图(比如 `.user-layout` 里加 background),只作用于用户端。
|
||||
|
||||
> 所有用户端的业务页面(`YoloDetection.vue`、`AiChat.vue` 等)都是渲染在 `user-content` 容器里,尽量保持这里的 padding / 背景一致,具体视觉再在业务页内做细化。
|
||||
|
||||
---
|
||||
|
||||
## 3. YOLO 检测页面修改指南(排版 / 布局 / 样式 / UI)
|
||||
|
||||
文件路径:`src/views/user_pages/YoloDetection.vue`
|
||||
|
||||
这个页面已经内置了 **三种布局模式**,并且使用了大量局部 SCSS + AntD 组件。下面分步骤说明如何操作。
|
||||
|
||||
### 3.1 布局模式(classic / vertical / grid)
|
||||
|
||||
关键代码:
|
||||
|
||||
- 布局枚举与状态:
|
||||
|
||||
```ts
|
||||
const layoutMode = ref<'classic' | 'vertical' | 'grid'>('classic')
|
||||
const layoutKey = ref(0)
|
||||
```
|
||||
|
||||
- 模板中的三个布局分支:
|
||||
|
||||
```vue
|
||||
<!-- 经典两列布局 -->
|
||||
<div v-if="layoutMode === 'classic'" class="detection-content classic-layout" :key="`classic-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
|
||||
<!-- 上下布局 -->
|
||||
<div v-if="layoutMode === 'vertical'" class="detection-content vertical-layout" :key="`vertical-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏详情布局 -->
|
||||
<div v-if="layoutMode === 'grid'" class="detection-content sidebar-layout" :key="`grid-${layoutKey}`">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
`layoutMode` 的切换来自用户端首页布局弹窗(`user_pages/index.vue`),会写入 `localStorage('yoloDetectionLayout')`。
|
||||
|
||||
**如果你要改某一种布局的排版:**
|
||||
|
||||
1. 在 `YoloDetection.vue` 模板里定位对应的 `div`:
|
||||
- 经典两列:`.classic-layout`
|
||||
- 上下布局:`.vertical-layout`
|
||||
- 侧边栏布局:`.sidebar-layout`
|
||||
2. 在同文件的 `<style scoped lang="scss">` 中搜索这些 class(例如搜索 `.classic-layout`):
|
||||
- 调整其中的 `a-row` / `a-col` 排布、卡片宽度、高度等。
|
||||
3. 如果你只想保留一种布局,比如只保留经典两列:
|
||||
- 可以在 template 中暂时注释掉 `vertical` 和 `grid` 这两段。
|
||||
- 或者在 script 中将 `layoutMode` 的类型改小,只允许 `'classic'`。
|
||||
|
||||
### 3.2 修改上传区域(左侧卡片)
|
||||
|
||||
上传部分主结构(classic 布局示例):
|
||||
|
||||
```vue
|
||||
<a-card title="文件上传" class="upload-card">
|
||||
<div class="upload-area">
|
||||
<a-upload-dragger ... class="beautiful-upload">
|
||||
...
|
||||
</a-upload-dragger>
|
||||
|
||||
<div v-if="fileList.length > 0" class="compact-preview">...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detection-params" v-if="fileList.length > 0">...
|
||||
</div>
|
||||
|
||||
<div class="upload-actions" v-if="fileList.length > 0">...
|
||||
</div>
|
||||
</a-card>
|
||||
```
|
||||
|
||||
对应样式(节选):
|
||||
|
||||
```scss
|
||||
.yolo-detection-page {
|
||||
.upload-card { ... }
|
||||
.upload-area { ... }
|
||||
.beautiful-upload { ... }
|
||||
.compact-preview { ... }
|
||||
.upload-actions { ... }
|
||||
}
|
||||
|
||||
// 深度作用 AntD Upload
|
||||
:deep(.ant-upload-dragger) {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**具体改法示例:**
|
||||
|
||||
- **想调整拖拽区域高度 / 圆角 / 背景:**
|
||||
1. 在 `<style scoped lang="scss">` 中搜索 `:deep(.ant-upload-dragger)`。
|
||||
2. 修改其中的 `border-radius`、`min-height`、`background` 等。
|
||||
|
||||
- **想改变已上传文件的缩略图排列方式:**
|
||||
1. 搜索 `.compact-preview`、`.preview-grid`、`.preview-card` 等 class。
|
||||
2. 例如,将 `display: grid` 的列数从 `3` 改为 `4`:
|
||||
- 修改 `grid-template-columns: repeat(3, 1fr);` 为 `repeat(4, 1fr);`。
|
||||
|
||||
- **想给“开始检测”按钮加一个 loading 状态颜色:**
|
||||
1. 上传按钮使用的是 `<a-button type="primary" :loading="detecting">`,全局颜色来自 `.ant-btn-primary`。
|
||||
2. 如果想只在该页面定制:
|
||||
|
||||
```scss
|
||||
.yolo-detection-page {
|
||||
.upload-actions {
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #22c55e; // 单独改为绿色
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 修改检测结果区域排版
|
||||
|
||||
经典布局右侧结果卡片结构:
|
||||
|
||||
```vue
|
||||
<a-card title="检测结果" class="result-card">
|
||||
<div v-if="detectionResults.length === 0" class="no-results">...</div>
|
||||
<div v-else class="results-list">
|
||||
<div class="results-grid">
|
||||
<div v-for="result in sortedDetectionResults" class="result-item">
|
||||
<div class="image-comparison">...
|
||||
<div class="result-details">...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
```
|
||||
|
||||
对应样式关键点:
|
||||
|
||||
- `.results-list`:控制滚动区域、高度和滚动条样式
|
||||
- `.result-item`:单个结果卡片的背景、边框、阴影
|
||||
- `.image-comparison` / `.image-pair` / `.image-wrapper`:左右对比图/视频布局
|
||||
- `.result-details` / `.detection-stats` / `.detection-tags`:文本统计信息
|
||||
|
||||
**操作示例:**
|
||||
|
||||
- **改结果区域整体高度 / 是否滚动:**
|
||||
1. 在 SCSS 中搜索 `.results-list`。
|
||||
2. 调整 `max-height` 或移除 `overflow-y: scroll !important;`,就可以变成自适应高度。
|
||||
|
||||
- **把“原图/检测结果”改成上下叠加而不是左右:**
|
||||
1. 找到 `.image-pair`:
|
||||
|
||||
```scss
|
||||
.image-pair {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
2. 改为列方向:
|
||||
|
||||
```scss
|
||||
.image-pair {
|
||||
display: flex;
|
||||
flex-direction: column; // 上下排列
|
||||
gap: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
- **调整图片容器比例 / 加圆角阴影:**
|
||||
1. 修改 `.image-wrapper`:`height: 160px;` 改为你想要的高度或使用 `aspect-ratio`。
|
||||
2. 修改 `border-radius` / `box-shadow` 实现更柔和的卡片效果。
|
||||
|
||||
### 3.4 深度选择器 `:deep` 使用说明
|
||||
|
||||
在 `YoloDetection.vue` 中多次使用了 `:deep(...)` 来覆盖 AntD 子组件样式,例如:
|
||||
|
||||
```scss
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-list) {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
**规则:**
|
||||
|
||||
- 页面级样式修改尽量写在该 `.vue` 的 `<style scoped>` 中,用 `:deep` 定位到 AntD 的 DOM 结构。
|
||||
- 全局通用样式统一放在 `src/styles/index.scss`,不要在页面里频繁写过多全局覆盖。
|
||||
|
||||
**如何找到 AntD 的 class 名称?**
|
||||
|
||||
1. 浏览器打开页面,F12 查看对应组件 DOM 结构。
|
||||
2. 复制最内层需要修改的 class(如 `.ant-card-head`)。
|
||||
3. 在该页面样式中写:
|
||||
|
||||
```scss
|
||||
.my-custom-card {
|
||||
:deep(.ant-card-head) {
|
||||
// 自定义头部样式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 修改 AI 助手页面(整体方法类似)
|
||||
|
||||
AI 助手页面:`src/views/user_pages/AiChat.vue`
|
||||
|
||||
- 也有多种布局:`default`(左右)、`compact`(上下)、`wide`(全屏消息 + 浮动侧边栏)。
|
||||
- 样式组织方式与 YOLO 检测类似:
|
||||
- 外层 `.ai-chat-page`
|
||||
- 内部 `.chat-container` / `.chat-sidebar` / `.chat-main` / `.messages-wrapper` / `.composer` 等
|
||||
- 使用 `:deep` 覆盖 AntD 的 `a-card`、`a-input-search`、`a-list` 等组件
|
||||
|
||||
**如果你已经掌握了上面 YOLO 页面改法:**
|
||||
|
||||
- 改 AI 助手排版时只需:
|
||||
1. 找到对应布局分支(`currentLayout === 'default' | 'compact' | 'wide'`)
|
||||
2. 在 `<style scoped lang="scss">` 中编辑对应 class 的样式即可。
|
||||
|
||||
---
|
||||
|
||||
## 5. 实战建议:改 UI 的基本步骤
|
||||
|
||||
1. **先确认层级:**
|
||||
- 是全局都要变?(颜色、按钮、弹窗 -> `styles/index.scss`, `styles/variables.scss`, `hertz_theme.ts`)
|
||||
- 只改某个模块?(YOLO / AI / 管理端 / 用户端布局 -> 各自 `.vue` 的 scoped 样式)
|
||||
|
||||
2. **用浏览器开发者工具查 DOM 和 class:**
|
||||
- 看清楚 AntD 组件最终渲染出的结构和 class
|
||||
- 再决定用 `:deep(.ant-xxx)` 还是自定义的 `.my-block` 去写样式
|
||||
|
||||
3. **在对应 `.vue` 文件底部 `<style scoped lang="scss">` 中改局部样式:**
|
||||
- 保持 class 命名有语义:`.yolo-detection-page`、`.upload-area`、`.results-list` 等
|
||||
|
||||
4. **共用样式尽量抽到全局:**
|
||||
- 例如卡片风格在 `variables.scss` + `index.scss` 抽成 mixin 或全局 class
|
||||
|
||||
5. **改完后检查响应式:**
|
||||
- `YoloDetection.vue` 和 AI 助手都内置了 `@media` 段,记得同步修改,以免移动端错位。
|
||||
|
||||
---
|
||||
|
||||
## 6. 如果要新增一个“风格类似 YOLO 的新页面”
|
||||
|
||||
1. 在 `src/views/user_pages/` 下新建 `MyFeature.vue`
|
||||
2. 参考 `YoloDetection.vue` 的结构:
|
||||
- 顶部 `.page-header`
|
||||
- 中间 `a-row` + `a-col` 做主布局
|
||||
- 底部 `<style scoped lang="scss">` 里用 `.my-feature-page { ... }` 包裹所有样式
|
||||
3. 使用全局变量和 mixin:
|
||||
|
||||
```scss
|
||||
.my-feature-page {
|
||||
background: var(--theme-page-bg, #f5f5f5);
|
||||
|
||||
.my-card {
|
||||
@include card-style;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 在 `src/router/user_menu_ai.ts` 中为用户端菜单新增一项,对应 `MyFeature.vue`。
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user