Initial commit: book-mcp 教辅处理 MCP server + Arco Vue 管理端
后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)
存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一
前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)
部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)
测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
This commit is contained in:
212
frontend/.cursorrules
Normal file
212
frontend/.cursorrules
Normal file
@@ -0,0 +1,212 @@
|
||||
# Frontend Cursor Rules
|
||||
|
||||
## 1. 基础规范
|
||||
- Vue 3 Composition API (`<script setup>`)
|
||||
- TypeScript
|
||||
- Arco Design Vue 2.x(组件自动导入)
|
||||
|
||||
## 2. 目录结构
|
||||
- **API (`src/api/`)**: API 接口定义
|
||||
- 按功能模块组织文件(如 `user.ts`、`dashboard.ts`)
|
||||
- 使用 Axios 进行 HTTP 请求
|
||||
- 统一在 `src/api/interceptor.ts` 配置拦截器
|
||||
- **组件 (`src/components/`)**: 全局可复用组件
|
||||
- 按功能模块组织子目录
|
||||
- 全局组件在 `src/components/index.ts` 中注册
|
||||
- 使用 Arco Design 组件时无需手动导入(自动导入已配置)
|
||||
- 注意:模块专用组件应放在对应模块目录下的 `components/` 文件夹,而非全局 `src/components/`
|
||||
- **视图 (`src/views/`)**: 页面级组件
|
||||
- 按功能模块组织目录
|
||||
- 每个模块包含 `index.vue` 和 `locale/` 目录(国际化文案)
|
||||
- 模块专用组件放在模块目录下的 `components/` 文件夹(如 `src/views/User/UserInfo/components/`)
|
||||
- **路由 (`src/router/`)**: 路由配置
|
||||
- 路由定义在 `router/routes/` 目录,按模块组织
|
||||
- 路由守卫在 `router/guard/` 目录
|
||||
- **状态管理 (`src/store/`)**: Pinia Store
|
||||
- 按模块组织 store(如 `user/`、`app/`)
|
||||
- 使用 `defineStore` 定义 store
|
||||
- **工具 (`src/utils/`)**: 通用工具函数
|
||||
- **Hooks (`src/hooks/`)**: 组合式函数
|
||||
- 可复用的 Composition API 逻辑
|
||||
|
||||
## 2.1. 命名规范(重要)
|
||||
- **目录命名**:必须使用**驼峰命名法(PascalCase)**
|
||||
- ✅ `UserManage/`、`QuestionBank/`、`PaperManage/`、`UserInfo/`
|
||||
- ❌ `user-manage/`、`question-bank/`、`user-info/`
|
||||
- **组件文件命名**:必须使用**驼峰命名法(PascalCase)**
|
||||
- ✅ `UserList.vue`、`RoleManage.vue`、`MenuManage.vue`、`UserInfoHeader.vue`
|
||||
- ❌ `user-list.vue`、`role-manage.vue`、`user-info-header.vue`
|
||||
- **独立子页面**:必须单独建立文件夹
|
||||
- 如果页面有独立的子页面(如用户详情页),应单独建立文件夹
|
||||
- 示例:`User/UserInfo/`、`User/UserSetting/`
|
||||
- 每个子页面目录包含 `index.vue` 和可选的 `components/` 文件夹
|
||||
|
||||
## 2.2. 组件化原则
|
||||
- **公共组件**:放在 `src/components/` 目录下
|
||||
- 可在多个页面复用的组件
|
||||
- **页面私有组件**:放在页面目录下的 `components/` 文件夹
|
||||
- 仅当前页面使用的组件
|
||||
- 示例:`User/UserInfo/components/UserInfoHeader.vue`
|
||||
- **页面组件化**:能够拆成组件的都组件化
|
||||
- 将复杂页面拆分为多个小组件
|
||||
- 示例:`UserManage/index.vue` 包含 `UserList`、`RoleManage`、`MenuManage` 三个组件
|
||||
|
||||
## 2.3. 页面结构示例
|
||||
```
|
||||
views/
|
||||
├── User/ # 用户个人信息页面
|
||||
│ ├── UserInfo/ # 用户信息子页面(独立文件夹)
|
||||
│ │ ├── components/ # 私有组件(仅当前页面用)
|
||||
│ │ │ ├── UserInfoHeader.vue
|
||||
│ │ │ ├── LatestActivity.vue
|
||||
│ │ │ └── MyProject.vue
|
||||
│ │ └── index.vue # 详情页主文件
|
||||
│ └── UserSetting/ # 用户设置子页面(独立文件夹)
|
||||
│ ├── components/
|
||||
│ │ ├── UserPanel.vue
|
||||
│ │ └── BasicInformation.vue
|
||||
│ └── index.vue
|
||||
│
|
||||
└── UserManage/ # 用户管理页面(管理员功能)
|
||||
├── components/ # 组件化(可复用)
|
||||
│ ├── UserList.vue # 用户列表组件
|
||||
│ ├── RoleManage.vue # 角色管理组件
|
||||
│ └── MenuManage.vue # 菜单管理组件
|
||||
└── index.vue # 主页面(标签页容器)
|
||||
```
|
||||
|
||||
## 3. 代码风格
|
||||
|
||||
### 格式化规范(遵循 `.prettierrc.js`)
|
||||
- **缩进**: 2 个空格(`tabWidth: 2`)
|
||||
- **引号**: 单引号(`singleQuote: true`)
|
||||
- **分号**: 必须使用分号(`semi: true`)
|
||||
- **行宽**: 80 字符(`printWidth: 80`)
|
||||
- **对象属性引号**: 保持一致(`quoteProps: 'consistent'`)
|
||||
- **HTML 空白**: 严格模式(`htmlWhitespaceSensitivity: 'strict'`)
|
||||
- **Vue 脚本样式缩进**: 启用(`vueIndentScriptAndStyle: true`)
|
||||
|
||||
### 代码检查规范(遵循 `.eslintrc.js`)
|
||||
- **代码风格**: Airbnb JavaScript Style Guide
|
||||
- **TypeScript**: 使用 `@typescript-eslint/recommended` 规则
|
||||
- **Vue 3**: 使用 `plugin:vue/vue3-recommended` 规则
|
||||
- **导入**: 使用 `plugin:import` 规则,支持 TypeScript 路径解析
|
||||
- **关键规则**:
|
||||
- 事件名使用 camelCase(`vue/custom-event-name-casing`)
|
||||
- 允许 `@ts-ignore` 注释(`@typescript-eslint/ban-ts-comment: 0`)
|
||||
- 允许 `any` 类型(`@typescript-eslint/no-explicit-any: 0`)
|
||||
- 导入时不需要扩展名(`import/extensions`)
|
||||
- 生产环境禁止 `debugger`,开发环境允许
|
||||
|
||||
### TypeScript
|
||||
- 使用 `camelCase` 命名变量和函数
|
||||
- 使用 `PascalCase` 命名类和组件
|
||||
- 使用类型提示,但不需要比 Python 更严格
|
||||
- 避免使用 `abstract` 类和 `protected` 修饰符
|
||||
- 命名方式需与后端 Python 代码保持一致(API 调用时保持命名一致性)
|
||||
|
||||
### Vue 组件
|
||||
- 使用 `<script setup>` 语法
|
||||
- **组件文件名必须使用 PascalCase**(如 `UserProfile.vue`、`UserList.vue`)
|
||||
- **目录名必须使用 PascalCase**(如 `UserManage/`、`QuestionBank/`)
|
||||
- Props 和 Emits 使用 `defineProps` 和 `defineEmits`
|
||||
- 使用 `ref` 和 `reactive` 管理响应式状态
|
||||
- 使用 `computed` 和 `watch` 处理派生状态和副作用
|
||||
- 事件名使用 camelCase(如 `@update:value`)
|
||||
|
||||
### 样式
|
||||
- 使用 Less 作为 CSS 预处理器
|
||||
- 组件样式使用 `<style lang="less" scoped>`
|
||||
- 全局样式在 `src/assets/style/global.less`
|
||||
- 使用 Arco Design CSS 变量(如 `var(--color-bg-1)`)
|
||||
- 响应式使用 Less 变量 `@screen-*`(定义在 `src/assets/style/breakpoint.less`)
|
||||
|
||||
## 4. UI 组件使用
|
||||
|
||||
### Arco Design 组件
|
||||
- **优先使用 Arco Design 组件**: 优先使用 Arco Design Vue 提供的组件
|
||||
- **自动导入**: Arco 组件已配置自动导入,无需手动 import
|
||||
- **主题变量**: 使用 Arco Design CSS 变量,支持主题切换
|
||||
- **图标**: 使用 `@arco-design/web-vue/es/icon` 中的图标
|
||||
- **样式修改**: 不要擅自修改 Arco Design 组件的默认样式,除非收到明确的修改命令
|
||||
|
||||
### 自定义组件
|
||||
- 放置在 `src/components/` 目录
|
||||
- 按功能模块组织子目录
|
||||
- 全局组件在 `src/components/index.ts` 中注册
|
||||
|
||||
## 5. API 调用
|
||||
|
||||
### 请求配置
|
||||
- 使用 `src/api/interceptor.ts` 中的 Axios 实例
|
||||
- 统一错误处理在拦截器中配置
|
||||
- Token 认证通过请求头自动添加
|
||||
|
||||
### API 文件组织
|
||||
- 按功能模块组织 API 文件(如 `user.ts`、`dashboard.ts`)
|
||||
- 导出函数命名使用 camelCase
|
||||
- 使用 TypeScript 类型定义请求和响应
|
||||
|
||||
## 6. 状态管理
|
||||
|
||||
### Pinia Store
|
||||
- 使用 `defineStore` 定义 store
|
||||
- Store 文件按模块组织在 `src/store/modules/` 目录
|
||||
- 使用组合式 API 风格(`setup` 函数)
|
||||
|
||||
### 状态命名
|
||||
- State 使用 camelCase
|
||||
- Actions 使用 camelCase,动词开头(如 `fetchUserInfo`)
|
||||
- Getters 使用 camelCase
|
||||
|
||||
## 7. 路由
|
||||
|
||||
### 路由配置
|
||||
- 路由定义在 `router/routes/` 目录,按模块组织
|
||||
- 使用懒加载:`component: () => import('@/views/xxx/index.vue')`
|
||||
- 路由元信息在 `meta` 字段中定义
|
||||
|
||||
### 路由守卫
|
||||
- 认证守卫:`router/guard/userLoginInfo.ts`
|
||||
- 权限守卫:`router/guard/permission.ts`
|
||||
- 全局守卫:`router/guard/index.ts`
|
||||
|
||||
## 8. 国际化
|
||||
- 使用 `useI18n()` 获取 `t` 函数
|
||||
- 页面文案:`views/[module]/locale/`,全局文案:`src/locale/`
|
||||
- 文案 key 使用点号分隔(如 `user.login.title`)
|
||||
|
||||
## 9. 数据可视化
|
||||
|
||||
### ECharts
|
||||
- 使用 `vue-echarts` 封装 ECharts
|
||||
- 图表组件统一在 `src/components/chart/` 目录
|
||||
- 图表配置使用 `hooks/chart-option.ts` 统一管理
|
||||
|
||||
## 10. 开发规范
|
||||
|
||||
### 环境变量
|
||||
- 使用 Vite 环境变量(`VITE_*` 前缀)
|
||||
- 开发环境:`.env.development`
|
||||
- 生产环境:`.env.production`
|
||||
- API 地址:`VITE_API_BASE_URL`
|
||||
|
||||
### Mock 数据
|
||||
- 开发环境支持 Mock.js
|
||||
- Mock 配置在 `src/mock/` 目录
|
||||
- 使用 `src/utils/setup-mock.ts` 设置 Mock
|
||||
|
||||
### 代码质量
|
||||
- **ESLint**: 代码检查(配置在 `.eslintrc.js`),遵循 Airbnb 风格和 Vue 3 规范
|
||||
- **Prettier**: 代码格式化(配置在 `.prettierrc.js`),2空格缩进、单引号、分号
|
||||
- **Stylelint**: 样式检查(配置在 `.stylelintrc.js`)
|
||||
- **lint-staged**: 提交前自动格式化(配置在 `package.json`)
|
||||
- **重要**: 生成的代码必须符合 Prettier 和 ESLint 规范,避免格式化错误
|
||||
|
||||
## 11. 性能优化
|
||||
- 路由级别懒加载
|
||||
- 组件按需加载
|
||||
|
||||
## 12. 响应式设计
|
||||
- 使用 Less 变量 `@screen-xs` 到 `@screen-xxl`
|
||||
- 使用 `hooks/responsive.ts` 获取响应式状态
|
||||
5
frontend/.env.development
Normal file
5
frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
# book-mcp 前端 — 开发环境(用 vite proxy 转发 /mcp)
|
||||
VITE_API_BASE_URL=/mcp
|
||||
VITE_TRACKING_APP_KEY=book-mcp-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_TRACKING_APP_KEY=doluedu-arco-admin-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
|
||||
# 生产环境配置(生产环境使用 .env.production)
|
||||
# VITE_API_BASE_URL=https://api.example.com
|
||||
3
frontend/.env.production
Normal file
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_TRACKING_APP_KEY=doluedu-arco-admin-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
3
frontend/.eslintignore
Normal file
3
frontend/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
/*.json
|
||||
/*.js
|
||||
dist
|
||||
70
frontend/.eslintrc.js
Normal file
70
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
// Parser that checks the content of the <script> tag
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
'browser': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
// Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: path.resolve(__dirname, './tsconfig.json'),
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 1,
|
||||
// Vue: Recommended rules to be closed or modify
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/singleline-html-element-content-newline': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
// Vue: Add extra rules
|
||||
'vue/custom-event-name-casing': [2, 'camelCase'],
|
||||
'vue/no-v-text': 1,
|
||||
'vue/padding-line-between-blocks': 1,
|
||||
'vue/require-direct-export': 1,
|
||||
'vue/multi-word-component-names': 0,
|
||||
// Allow @ts-ignore comment
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/no-unused-vars': 1,
|
||||
'@typescript-eslint/no-empty-function': 1,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'import/extensions': [
|
||||
2,
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'no-param-reassign': 0,
|
||||
'prefer-regex-literals': 0,
|
||||
'import/no-extraneous-dependencies': 0,
|
||||
},
|
||||
};
|
||||
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
4
frontend/.husky/commit-msg
Executable file
4
frontend/.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm commitlint --edit $1
|
||||
4
frontend/.husky/pre-commit
Executable file
4
frontend/.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint-staged
|
||||
7
frontend/.prettierignore
Normal file
7
frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
9
frontend/.prettierrc.js
Normal file
9
frontend/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
printWidth: 80,
|
||||
singleQuote: true,
|
||||
quoteProps: 'consistent',
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
vueIndentScriptAndStyle: true,
|
||||
};
|
||||
30
frontend/.stylelintrc.js
Normal file
30
frontend/.stylelintrc.js
Normal file
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-rational-order',
|
||||
'stylelint-config-prettier',
|
||||
'stylelint-config-recommended-vue',
|
||||
],
|
||||
defaultSeverity: 'warning',
|
||||
plugins: ['stylelint-order'],
|
||||
rules: {
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: ['plugin'],
|
||||
},
|
||||
],
|
||||
'rule-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
except: ['after-single-line-comment', 'first-nested'],
|
||||
},
|
||||
],
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['deep'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
56
frontend/README.md
Normal file
56
frontend/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# book-mcp Frontend
|
||||
|
||||
DoLuEdu Arco Admin Vue 管理端骨架 + book-mcp 业务模块。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 1. 确保 book-mcp server 在跑 (8100)
|
||||
# 2. 装依赖
|
||||
npm install
|
||||
|
||||
# 3. dev server
|
||||
npm run dev # http://localhost:5176
|
||||
|
||||
# 4. 构建生产
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
- `vite.config.dev.ts` — dev proxy `/mcp → http://localhost:8100/mcp`
|
||||
- `.env.development` — `VITE_API_BASE_URL=/mcp`(用相对路径走 proxy)
|
||||
|
||||
## 业务模块
|
||||
|
||||
- `src/api/mcp.ts` — JSON-RPC 客户端(fastmcp 协议)
|
||||
- `src/api/book.ts` — book CRUD
|
||||
- `src/api/document.ts` — document / file CRUD + health
|
||||
- `src/views/BookManage/index.vue` — 列表页(路由 `/book/list`)
|
||||
- `src/views/BookManage/BookDetail/index.vue` — 详情页(路由 `/book/:id`)
|
||||
- `src/views/BookManage/components/BookList.vue` — 列表组件
|
||||
- `src/views/BookManage/components/DocumentItem.vue` — 文档折叠项
|
||||
|
||||
## MCP 后端
|
||||
|
||||
15 个工具,分类:
|
||||
|
||||
| 类别 | 工具 |
|
||||
|---|---|
|
||||
| 写入 | create_book / add_document / mark_html_ready / mark_json_ready / mark_failed / update_book_status |
|
||||
| 查询 | get_book / list_books / get_document / list_documents / get_document_url |
|
||||
| 辅助 | upload_to_rustfs / soft_delete_book / retry_failed |
|
||||
| 诊断 | health |
|
||||
|
||||
启动:launchd `ai.book-mcp.server`(端口 8100,stateless_http + json_response)。
|
||||
|
||||
## 模板来源
|
||||
|
||||
`DoLuEdu-Template/frontend/arco-admin-vue/` 复制而来。模板规则:禁止在模板里写业务,只在骨架基础上加。
|
||||
|
||||
## 关键约定
|
||||
|
||||
- Vue 3 + TypeScript + `<script setup>`
|
||||
- Arco Design Vue 2.x
|
||||
- 目录 PascalCase(`BookManage/` 不是 `book-manage/`)
|
||||
- API 用 axios 包 fastmcp JSON-RPC
|
||||
3
frontend/babel.config.js
Normal file
3
frontend/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: ['@vue/babel-plugin-jsx'],
|
||||
};
|
||||
3
frontend/commitlint.config.js
Normal file
3
frontend/commitlint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
15
frontend/components.d.ts
vendored
Normal file
15
frontend/components.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
19
frontend/config/plugin/arcoResolver.ts
Normal file
19
frontend/config/plugin/arcoResolver.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* If you use the template method for development, you can use the unplugin-vue-components plugin to enable on-demand loading support.
|
||||
* 按需引入
|
||||
* https://github.com/antfu/unplugin-vue-components
|
||||
* https://arco.design/vue/docs/start
|
||||
* Although the Pro project is full of imported components, this plugin will be used by default.
|
||||
* 虽然Pro项目中是全量引入组件,但此插件会默认使用。
|
||||
*/
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
export default function configArcoResolverPlugin() {
|
||||
const arcoResolverPlugin = Components({
|
||||
dirs: [], // Avoid parsing src/components. 避免解析到src/components
|
||||
deep: false,
|
||||
resolvers: [ArcoResolver()],
|
||||
});
|
||||
return arcoResolverPlugin;
|
||||
}
|
||||
12
frontend/config/plugin/arcoStyleImport.ts
Normal file
12
frontend/config/plugin/arcoStyleImport.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Theme import
|
||||
* 样式按需引入
|
||||
* https://github.com/arco-design/arco-plugins/blob/main/packages/plugin-vite-vue/README.md
|
||||
* https://arco.design/vue/docs/start
|
||||
*/
|
||||
import { vitePluginForArco } from '@arco-plugins/vite-vue';
|
||||
|
||||
export default function configArcoStyleImportPlugin() {
|
||||
const arcoResolverPlugin = vitePluginForArco({});
|
||||
return arcoResolverPlugin;
|
||||
}
|
||||
18
frontend/config/plugin/visualizer.ts
Normal file
18
frontend/config/plugin/visualizer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generation packaging analysis
|
||||
* 生成打包分析
|
||||
*/
|
||||
import visualizer from 'rollup-plugin-visualizer';
|
||||
import { isReportMode } from '../utils';
|
||||
|
||||
export default function configVisualizerPlugin() {
|
||||
if (isReportMode()) {
|
||||
return visualizer({
|
||||
filename: './node_modules/.cache/visualizer/stats.html',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
9
frontend/config/utils/index.ts
Normal file
9
frontend/config/utils/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Whether to generate package preview
|
||||
* 是否生成打包报告
|
||||
*/
|
||||
export default {};
|
||||
|
||||
export function isReportMode(): boolean {
|
||||
return process.env.REPORT === 'true';
|
||||
}
|
||||
51
frontend/config/vite.config.base.ts
Normal file
51
frontend/config/vite.config.base.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import svgLoader from 'vite-svg-loader';
|
||||
import configArcoStyleImportPlugin from './plugin/arcoStyleImport';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
svgLoader({ svgoConfig: {} }),
|
||||
configArcoStyleImportPlugin(),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@',
|
||||
replacement: resolve(__dirname, '../src'),
|
||||
},
|
||||
{
|
||||
find: 'assets',
|
||||
replacement: resolve(__dirname, '../src/assets'),
|
||||
},
|
||||
{
|
||||
find: 'vue-i18n',
|
||||
replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
|
||||
},
|
||||
{
|
||||
find: 'vue',
|
||||
replacement: 'vue/dist/vue.esm-bundler.js', // compile template
|
||||
},
|
||||
],
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {
|
||||
hack: `true; @import (reference) "${resolve(
|
||||
'src/assets/style/breakpoint.less'
|
||||
)}";`,
|
||||
},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
32
frontend/config/vite.config.dev.ts
Normal file
32
frontend/config/vite.config.dev.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
// import eslint from 'vite-plugin-eslint';
|
||||
import baseConfig from './vite.config.base';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'development',
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 5176,
|
||||
open: false,
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
proxy: {
|
||||
'/mcp': {
|
||||
target: 'http://localhost:8100',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/mcp/, '/mcp'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// eslint({
|
||||
// cache: false,
|
||||
// include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
|
||||
// exclude: ['node_modules'],
|
||||
// }),
|
||||
],
|
||||
},
|
||||
baseConfig
|
||||
);
|
||||
27
frontend/config/vite.config.prod.ts
Normal file
27
frontend/config/vite.config.prod.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import baseConfig from './vite.config.base';
|
||||
import configVisualizerPlugin from './plugin/visualizer';
|
||||
import configArcoResolverPlugin from './plugin/arcoResolver';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'production',
|
||||
plugins: [
|
||||
configVisualizerPlugin(),
|
||||
configArcoResolverPlugin(),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
arco: ['@arco-design/web-vue'],
|
||||
chart: ['echarts', 'vue-echarts'],
|
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
},
|
||||
baseConfig
|
||||
);
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DoLuEdu Template</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
14684
frontend/package-lock.json
generated
Normal file
14684
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
100
frontend/package.json
Normal file
100
frontend/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "doluedu-arco-admin-vue-template",
|
||||
"description": "DoLuEdu Arco Design Pro Vue admin template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"author": "DoLuEdu",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite --config ./config/vite.config.dev.ts",
|
||||
"build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts",
|
||||
"report": "cross-env REPORT=true npm run build",
|
||||
"preview": "npm run build && vite preview --host",
|
||||
"type:check": "vue-tsc --noEmit --skipLibCheck",
|
||||
"lint-staged": "npx lint-staged",
|
||||
"prepare": "node -e \"if (require('fs').existsSync('.git')) require('child_process').execSync('husky install', {stdio:'inherit'})\""
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.vue": [
|
||||
"stylelint --fix",
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{less,css}": [
|
||||
"stylelint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.44.7",
|
||||
"@vueuse/core": "^9.3.0",
|
||||
"arco-design-pro-vue": "^2.7.3",
|
||||
"axios": "^0.24.0",
|
||||
"dayjs": "^1.11.5",
|
||||
"echarts": "^5.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.0.23",
|
||||
"query-string": "^8.0.3",
|
||||
"sortablejs": "^1.15.0",
|
||||
"web-tracking-sdk": "^1.0.0",
|
||||
"vue": "^3.2.40",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arco-plugins/vite-vue": "^1.4.5",
|
||||
"@commitlint/cli": "^17.1.2",
|
||||
"@commitlint/config-conventional": "^17.1.0",
|
||||
"@types/lodash": "^4.14.186",
|
||||
"@types/mockjs": "^1.0.7",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/sortablejs": "^1.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
||||
"@typescript-eslint/parser": "^5.40.0",
|
||||
"@vitejs/plugin-vue": "^3.1.2",
|
||||
"@vitejs/plugin-vue-jsx": "^2.0.1",
|
||||
"@vue/babel-plugin-jsx": "^1.1.1",
|
||||
"consola": "^2.15.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.6.0",
|
||||
"husky": "^8.0.1",
|
||||
"less": "^4.1.3",
|
||||
"lint-staged": "^13.0.3",
|
||||
"mockjs": "^1.1.0",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"rollup": "^3.9.1",
|
||||
"rollup-plugin-visualizer": "^5.8.2",
|
||||
"stylelint": "^14.13.0",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-config-recommended-vue": "^1.4.0",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"stylelint-order": "^5.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"unplugin-vue-components": "^0.24.1",
|
||||
"vite": "^3.2.5",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-svg-loader": "^3.6.0",
|
||||
"vue-tsc": "^1.0.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "^2.56.3"
|
||||
}
|
||||
}
|
||||
26
frontend/src/App.vue
Normal file
26
frontend/src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
<global-setting />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import GlobalSetting from '@/components/global-setting/index.vue';
|
||||
import useLocale from '@/hooks/locale';
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
switch (currentLocale.value) {
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'en-US':
|
||||
return enUS;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
63
frontend/src/api/auth.ts
Normal file
63
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type UserType = 'system_admin' | 'user';
|
||||
|
||||
export interface LoginRes {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
loginName?: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
userType: UserType;
|
||||
passwordChanged: boolean;
|
||||
roles: string[];
|
||||
realName?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
username: string;
|
||||
loginName?: string;
|
||||
realName?: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
userType: UserType;
|
||||
passwordChanged: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const demoUser: LoginRes = {
|
||||
accessToken: 'template-demo-token',
|
||||
userId: 'template-admin',
|
||||
username: 'admin',
|
||||
realName: '平台管理员',
|
||||
role: 'system_admin',
|
||||
permissions: ['*'],
|
||||
userType: 'system_admin',
|
||||
passwordChanged: true,
|
||||
roles: ['system_admin'],
|
||||
};
|
||||
|
||||
export async function login(data: LoginData) {
|
||||
if (!data.username || !data.password) throw new Error('请输入账号和密码');
|
||||
return { data: { ...demoUser, username: data.username } };
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return { data: null };
|
||||
}
|
||||
|
||||
export async function getUserInfo() {
|
||||
return { data: demoUser as UserInfo };
|
||||
}
|
||||
|
||||
export async function changePassword(_data?: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}) {
|
||||
return { data: null };
|
||||
}
|
||||
106
frontend/src/api/book.ts
Normal file
106
frontend/src/api/book.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/** book 业务 API — 包 mcp 的 create_book / list_books / get_book / update_book_status / soft_delete_book */
|
||||
import { callTool } from './mcp';
|
||||
|
||||
export interface Book {
|
||||
id: number;
|
||||
name: string;
|
||||
subject?: string;
|
||||
grade?: string;
|
||||
version?: string;
|
||||
type?: string;
|
||||
zip_path: string;
|
||||
zip_sha256: string;
|
||||
zip_size: number;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed' | 'partial';
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
error_msg?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface ListBooksResp {
|
||||
books: Book[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export async function listBooks(params: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ListBooksResp> {
|
||||
return callTool<ListBooksResp>('list_books', params);
|
||||
}
|
||||
|
||||
export async function getBook(bookId: number): Promise<Book & {
|
||||
documents: BookDocument[];
|
||||
}> {
|
||||
return callTool('get_book', { book_id: bookId });
|
||||
}
|
||||
|
||||
export async function createBook(params: {
|
||||
name: string;
|
||||
zip_path: string;
|
||||
zip_sha256: string;
|
||||
zip_size?: number;
|
||||
subject?: string;
|
||||
grade?: string;
|
||||
version?: string;
|
||||
type?: string;
|
||||
created_by?: string;
|
||||
}): Promise<{ ok: boolean; book_id: number; status: string; idempotent: boolean }> {
|
||||
return callTool('create_book', params);
|
||||
}
|
||||
|
||||
export async function updateBookStatus(
|
||||
bookId: number,
|
||||
status: string,
|
||||
error_msg?: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
return callTool('update_book_status', { book_id: bookId, status, error_msg });
|
||||
}
|
||||
|
||||
export async function softDeleteBook(bookId: number): Promise<{ ok: boolean }> {
|
||||
return callTool('soft_delete_book', { book_id: bookId });
|
||||
}
|
||||
|
||||
export async function retryFailed(bookId: number): Promise<{
|
||||
ok: boolean;
|
||||
retried_count: number;
|
||||
}> {
|
||||
return callTool('retry_failed', { book_id: bookId });
|
||||
}
|
||||
|
||||
// ----- 嵌套类型 -----
|
||||
export interface BookDocument {
|
||||
book_id: number;
|
||||
doc_id: string;
|
||||
original_filename: string;
|
||||
original_path: string;
|
||||
title?: string;
|
||||
doc_type: string;
|
||||
order_index: number;
|
||||
status: 'pending' | 'html_ready' | 'json_ready' | 'failed';
|
||||
error_msg?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
files?: BookFile[];
|
||||
}
|
||||
|
||||
export interface BookFile {
|
||||
id: number;
|
||||
doc_id: string;
|
||||
file_type: 'docx' | 'html' | 'json' | 'preview_png' | 'error_log';
|
||||
object_key: string;
|
||||
object_bucket: string;
|
||||
size: number;
|
||||
checksum?: string;
|
||||
url?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
}
|
||||
92
frontend/src/api/document.ts
Normal file
92
frontend/src/api/document.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/** document / file 业务 API */
|
||||
import { callTool } from './mcp';
|
||||
import type { BookDocument, BookFile } from './book';
|
||||
|
||||
export type { BookDocument, BookFile };
|
||||
|
||||
export async function addDocument(params: {
|
||||
book_id: number;
|
||||
original_filename: string;
|
||||
original_path: string;
|
||||
title?: string;
|
||||
doc_type?: string;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
doc_id: string;
|
||||
order_index: number;
|
||||
status: string;
|
||||
doc_type: string;
|
||||
idempotent: boolean;
|
||||
}> {
|
||||
return callTool('add_document', params);
|
||||
}
|
||||
|
||||
export async function getDocument(docId: string): Promise<BookDocument & {
|
||||
files: BookFile[];
|
||||
}> {
|
||||
return callTool('get_document', { doc_id: docId });
|
||||
}
|
||||
|
||||
export async function listDocuments(params: {
|
||||
book_id: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<{
|
||||
documents: BookDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}> {
|
||||
return callTool('list_documents', params);
|
||||
}
|
||||
|
||||
export async function markHtmlReady(params: {
|
||||
doc_id: string;
|
||||
html_url: string;
|
||||
html_size: number;
|
||||
formulas?: number;
|
||||
images?: number;
|
||||
chars?: number;
|
||||
}): Promise<{ ok: boolean; file_id: number; url: string }> {
|
||||
return callTool('mark_html_ready', params);
|
||||
}
|
||||
|
||||
export async function markJsonReady(params: {
|
||||
doc_id: string;
|
||||
json_url: string;
|
||||
json_size: number;
|
||||
schema_version?: string;
|
||||
}): Promise<{ ok: boolean; file_id: number; url: string }> {
|
||||
return callTool('mark_json_ready', params);
|
||||
}
|
||||
|
||||
export async function markFailed(params: {
|
||||
doc_id: string;
|
||||
error_msg: string;
|
||||
stage?: 'html' | 'json' | 'upload';
|
||||
}): Promise<{ ok: boolean }> {
|
||||
return callTool('mark_failed', params);
|
||||
}
|
||||
|
||||
export async function getDocumentUrl(
|
||||
doc_id: string,
|
||||
file_type: 'html' | 'json' | 'docx' | 'error_log',
|
||||
): Promise<{ file_type: string; url: string; size: number; checksum?: string } | null> {
|
||||
return callTool('get_document_url', { doc_id, file_type });
|
||||
}
|
||||
|
||||
export async function uploadToRustfs(params: {
|
||||
local_path: string;
|
||||
key: string;
|
||||
bucket?: string;
|
||||
}): Promise<{ url: string; key: string; bucket: string; size: number; checksum: string }> {
|
||||
return callTool('upload_to_rustfs', params);
|
||||
}
|
||||
|
||||
export async function health(): Promise<{
|
||||
wordparse: boolean;
|
||||
mongo: boolean;
|
||||
}> {
|
||||
return callTool('health', {});
|
||||
}
|
||||
7
frontend/src/api/interceptor.ts
Normal file
7
frontend/src/api/interceptor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface HttpResponse<T = unknown> {
|
||||
status?: string;
|
||||
code?: number;
|
||||
data: T;
|
||||
msg?: string;
|
||||
message?: string;
|
||||
}
|
||||
82
frontend/src/api/mcp.ts
Normal file
82
frontend/src/api/mcp.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* MCP JSON-RPC 客户端 — 调 book-mcp 的工具
|
||||
*
|
||||
* fastmcp 走 JSON-RPC 2.0,POST /mcp,body:
|
||||
* {"jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{"name":"...","arguments":{...}}}
|
||||
*
|
||||
* 成功:{"jsonrpc":"2.0","id":1,"result":{"content":[...], "data": <实际返回值>}}
|
||||
* 失败:{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"..."}}
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
export interface McpCallResult<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8100/mcp',
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// 拦截器:错误时弹 Arco Message
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err) => {
|
||||
const msg = err?.response?.data?.error?.message
|
||||
|| err?.response?.data?.message
|
||||
|| err.message
|
||||
|| 'unknown error';
|
||||
Message.error(`MCP 错误:${msg}`);
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
let _id = 0;
|
||||
function nextId() {
|
||||
_id += 1;
|
||||
return _id;
|
||||
}
|
||||
|
||||
/** 调 MCP 工具 */
|
||||
export async function callTool<T = unknown>(
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const { data } = await http.post('', {
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || JSON.stringify(data.error));
|
||||
}
|
||||
// fastmcp 返回结构:{ result: { content: [...], structuredContent: <actual>, isError } }
|
||||
const result = data.result;
|
||||
if (result?.isError) {
|
||||
const errText = result.content?.[0]?.text || 'tool error';
|
||||
throw new Error(errText);
|
||||
}
|
||||
// 优先 structuredContent(fastmcp 4.0 标准),fallback 到 data 字段
|
||||
return (result?.structuredContent ?? result?.data ?? result) as T;
|
||||
}
|
||||
|
||||
/** 列所有工具(调试用) */
|
||||
export async function listTools(): Promise<McpTool[]> {
|
||||
const { data } = await http.post('', {
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/list',
|
||||
});
|
||||
return data.result?.tools || [];
|
||||
}
|
||||
|
||||
export default http;
|
||||
23
frontend/src/api/message.ts
Normal file
23
frontend/src/api/message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface MessageRecord {
|
||||
id: number;
|
||||
type: 'message' | 'notice' | 'todo';
|
||||
title: string;
|
||||
subTitle: string;
|
||||
content: string;
|
||||
time: string;
|
||||
status: 0 | 1;
|
||||
avatar?: string;
|
||||
messageType?: number;
|
||||
}
|
||||
|
||||
export type MessageListType = MessageRecord[];
|
||||
|
||||
const messages: MessageRecord[] = [];
|
||||
|
||||
export async function queryMessageList() {
|
||||
return { data: messages };
|
||||
}
|
||||
|
||||
export async function setMessageStatus(_params?: { ids: number[] }) {
|
||||
return { data: messages };
|
||||
}
|
||||
7
frontend/src/api/user.ts
Normal file
7
frontend/src/api/user.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface MenuListResponse {
|
||||
data: unknown[];
|
||||
}
|
||||
|
||||
export async function getMenuList(): Promise<MenuListResponse> {
|
||||
return { data: [] };
|
||||
}
|
||||
BIN
frontend/src/assets/images/login-banner.png
Normal file
BIN
frontend/src/assets/images/login-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
12
frontend/src/assets/logo.svg
Normal file
12
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
19
frontend/src/assets/style/breakpoint.less
Normal file
19
frontend/src/assets/style/breakpoint.less
Normal file
@@ -0,0 +1,19 @@
|
||||
// ==============breakpoint============
|
||||
|
||||
// Extra small screen / phone
|
||||
@screen-xs: 480px;
|
||||
|
||||
// Small screen / tablet
|
||||
@screen-sm: 576px;
|
||||
|
||||
// Medium screen / desktop
|
||||
@screen-md: 768px;
|
||||
|
||||
// Large screen / wide desktop
|
||||
@screen-lg: 992px;
|
||||
|
||||
// Extra large screen / full hd
|
||||
@screen-xl: 1200px;
|
||||
|
||||
// Extra extra large screen / large desktop
|
||||
@screen-xxl: 1600px;
|
||||
94
frontend/src/assets/style/global.less
Normal file
94
frontend/src/assets/style/global.less
Normal file
@@ -0,0 +1,94 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-1);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.echarts-tooltip-diy {
|
||||
background: linear-gradient(
|
||||
304.17deg,
|
||||
rgba(253, 254, 255, 0.6) -6.04%,
|
||||
rgba(244, 247, 252, 0.6) 85.2%
|
||||
) !important;
|
||||
border: none !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
/* Note: backdrop-filter has minimal browser support */
|
||||
|
||||
border-radius: 6px !important;
|
||||
.content-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 9px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 164px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tooltip-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.tooltip-title,
|
||||
.tooltip-value {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
color: #1d2129;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tooltip-item-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
& > .arco-card-header {
|
||||
height: auto;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
}
|
||||
& > .arco-card-body {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
border-color: rgb(var(--gray-2));
|
||||
}
|
||||
|
||||
.arco-table-cell {
|
||||
.circle {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--blue-6));
|
||||
&.pass {
|
||||
background-color: rgb(var(--green-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/breadcrumb/index.vue
Normal file
35
frontend/src/components/breadcrumb/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<a-breadcrumb class="container-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as PropType<string[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-breadcrumb {
|
||||
margin: 16px 0;
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
color: rgb(var(--gray-6));
|
||||
&:last-child {
|
||||
color: rgb(var(--gray-8));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/components/chart/index.vue
Normal file
47
frontend/src/components/chart/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<VCharts
|
||||
v-if="renderChart"
|
||||
:option="options"
|
||||
:autoresize="autoResize"
|
||||
:style="{ width, height }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick } from 'vue';
|
||||
import VCharts from 'vue-echarts';
|
||||
// import { useAppStore } from '@/store';
|
||||
|
||||
defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
// const appStore = useAppStore();
|
||||
// const theme = computed(() => {
|
||||
// if (appStore.theme === 'dark') return 'dark';
|
||||
// return '';
|
||||
// });
|
||||
const renderChart = ref(false);
|
||||
// wait container expand
|
||||
nextTick(() => {
|
||||
renderChart.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
28
frontend/src/components/coming-soon/index.vue
Normal file
28
frontend/src/components/coming-soon/index.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="coming-soon-container">
|
||||
<a-result
|
||||
status="404"
|
||||
:subtitle="message || '该功能正在开发中,敬请期待...'"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-tool style="font-size: 64px; color: #c9cdd4;" />
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
message?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.coming-soon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/components/floating-actions/index.vue
Normal file
55
frontend/src/components/floating-actions/index.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="floating-actions">
|
||||
<a-tooltip content="常见问题" position="left">
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
class="action-btn"
|
||||
@click="handleFAQ"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-question-circle />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { openWindow } from '@/utils';
|
||||
|
||||
const handleFAQ = () => {
|
||||
// 打开常见问题页面(外部链接)
|
||||
openWindow('https://arco.design/vue/docs/pro/faq');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.floating-actions {
|
||||
position: fixed;
|
||||
bottom: 200px;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 0;
|
||||
|
||||
.action-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px 0 0 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/components/footer/index.vue
Normal file
16
frontend/src/components/footer/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<a-layout-footer class="footer">DoLuEdu</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/global-setting/block.vue
Normal file
79
frontend/src/components/global-setting/block.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="block">
|
||||
<h5 class="title">{{ title }}</h5>
|
||||
<div v-for="option in options" :key="option.name" class="switch-wrapper">
|
||||
<span>{{ $t(option.name) }}</span>
|
||||
<form-wrapper
|
||||
:type="option.type || 'switch'"
|
||||
:name="option.key"
|
||||
:default-value="option.defaultVal"
|
||||
@input-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import FormWrapper from './form-wrapper.vue';
|
||||
|
||||
interface OptionsProps {
|
||||
name: string;
|
||||
key: string;
|
||||
type?: string;
|
||||
defaultVal?: boolean | string | number;
|
||||
}
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array as PropType<OptionsProps[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
const appStore = useAppStore();
|
||||
const handleChange = async ({
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}) => {
|
||||
if (key === 'colorWeak') {
|
||||
document.body.style.filter = value ? 'invert(80%)' : 'none';
|
||||
}
|
||||
if (key === 'menuFromServer' && value) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
if (key === 'topMenu') {
|
||||
appStore.updateSettings({
|
||||
menuCollapse: false,
|
||||
});
|
||||
}
|
||||
appStore.updateSettings({ [key]: value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
39
frontend/src/components/global-setting/form-wrapper.vue
Normal file
39
frontend/src/components/global-setting/form-wrapper.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<a-input-number
|
||||
v-if="type === 'number'"
|
||||
:style="{ width: '80px' }"
|
||||
size="small"
|
||||
:default-value="(defaultValue as number)"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<a-switch
|
||||
v-else
|
||||
:default-checked="(defaultValue as boolean)"
|
||||
size="small"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Boolean, Number],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['inputChange']);
|
||||
const handleChange = (value: unknown) => {
|
||||
emit('inputChange', {
|
||||
value,
|
||||
key: props.name,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
98
frontend/src/components/global-setting/index.vue
Normal file
98
frontend/src/components/global-setting/index.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-drawer
|
||||
:width="300"
|
||||
unmount-on-close
|
||||
:visible="visible"
|
||||
:cancel-text="$t('settings.close')"
|
||||
:ok-text="$t('settings.copySettings')"
|
||||
@ok="copySettings"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<template #title> {{ $t('settings.title') }} </template>
|
||||
<Block :options="contentOpts" :title="$t('settings.content')" />
|
||||
<Block :options="othersOpts" :title="$t('settings.otherSettings')" />
|
||||
<a-alert>{{ $t('settings.alertContent') }}</a-alert>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useAppStore } from '@/store';
|
||||
import Block from './block.vue';
|
||||
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { t } = useI18n();
|
||||
const { copy } = useClipboard();
|
||||
const visible = computed(() => appStore.globalSettings);
|
||||
const contentOpts = computed(() => [
|
||||
{ name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar },
|
||||
{
|
||||
name: 'settings.menu',
|
||||
key: 'menu',
|
||||
defaultVal: appStore.menu,
|
||||
},
|
||||
{
|
||||
name: 'settings.topMenu',
|
||||
key: 'topMenu',
|
||||
defaultVal: appStore.topMenu,
|
||||
},
|
||||
{ name: 'settings.footer', key: 'footer', defaultVal: appStore.footer },
|
||||
{ name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar },
|
||||
{
|
||||
name: 'settings.menuFromServer',
|
||||
key: 'menuFromServer',
|
||||
defaultVal: appStore.menuFromServer,
|
||||
},
|
||||
{
|
||||
name: 'settings.menuWidth',
|
||||
key: 'menuWidth',
|
||||
defaultVal: appStore.menuWidth,
|
||||
type: 'number',
|
||||
},
|
||||
]);
|
||||
const othersOpts = computed(() => [
|
||||
{
|
||||
name: 'settings.colorWeak',
|
||||
key: 'colorWeak',
|
||||
defaultVal: appStore.colorWeak,
|
||||
},
|
||||
]);
|
||||
|
||||
const cancel = () => {
|
||||
appStore.updateSettings({ globalSettings: false });
|
||||
emit('cancel');
|
||||
};
|
||||
const copySettings = async () => {
|
||||
const text = JSON.stringify(appStore.$state, null, 2);
|
||||
await copy(text);
|
||||
Message.success(t('settings.copySettings.message'));
|
||||
};
|
||||
const setVisible = () => {
|
||||
appStore.updateSettings({ globalSettings: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.fixed-settings {
|
||||
position: fixed;
|
||||
top: 280px;
|
||||
right: 0;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
vertical-align: -4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/index.ts
Normal file
35
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { App } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
} from 'echarts/components';
|
||||
import Chart from './chart/index.vue';
|
||||
import Breadcrumb from './breadcrumb/index.vue';
|
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.component('Chart', Chart);
|
||||
Vue.component('Breadcrumb', Breadcrumb);
|
||||
},
|
||||
};
|
||||
161
frontend/src/components/menu/index.vue
Normal file
161
frontend/src/components/menu/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, ref, h, compile, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
// 默认打开仪表盘菜单
|
||||
const openKeys = ref<string[]>(['dashboard']);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
const goto = (item: RouteRecordRaw) => {
|
||||
// Open external link
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Eliminate external link side effects
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Trigger router change
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return; // Performance optimization
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (requiresAuth && (!hideInMenu || activeMenu)) {
|
||||
const menuOpenKeys = findMenuOpenKeys(
|
||||
(activeMenu || newRoute.name) as string
|
||||
);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [
|
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
|
||||
];
|
||||
}
|
||||
}, true);
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop')
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon
|
||||
? () => h(compile(`<${element?.meta?.icon}/>`))
|
||||
: null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item
|
||||
key={element?.name}
|
||||
v-slots={{ icon }}
|
||||
onClick={() => goto(element)}
|
||||
>
|
||||
{t(element?.meta?.locale || '')}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%;width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/components/menu/use-menu-tree.ts
Normal file
77
frontend/src/components/menu/use-menu-tree.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { computed } from 'vue';
|
||||
import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useAppStore } from '@/store';
|
||||
import appClientMenus from '@/router/app-menus';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const permission = usePermission();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const appRoute = computed(() => {
|
||||
if (appStore.menuFromServer) {
|
||||
return appStore.appAsyncMenus;
|
||||
}
|
||||
return appClientMenus;
|
||||
});
|
||||
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// no access
|
||||
if (!permission.accessRouter(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// hide parent menu if hideInMenu is true
|
||||
if (element.meta?.hideInMenu === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// leaf node
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
return element;
|
||||
}
|
||||
|
||||
// route filter hideInMenu true
|
||||
element.children = element.children.filter(
|
||||
(x) => x.meta?.hideInMenu !== true
|
||||
);
|
||||
|
||||
// Associated child node
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// the else logic
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
||||
129
frontend/src/components/message-box/index.vue
Normal file
129
frontend/src/components/message-box/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
|
||||
</a-result>
|
||||
<List
|
||||
:render-list="renderList"
|
||||
:unread-count="unreadCount"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
queryMessageList,
|
||||
setMessageStatus,
|
||||
MessageRecord,
|
||||
MessageListType,
|
||||
} from '@/api/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const { t } = useI18n();
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
];
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await queryMessageList();
|
||||
messageData.messageList = data;
|
||||
} catch (err) {
|
||||
// you can report use errorHandler or other
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id);
|
||||
await setMessageStatus({ ids });
|
||||
fetchSourceData();
|
||||
}
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter(
|
||||
(item) => messageType.value === item.type
|
||||
);
|
||||
});
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length;
|
||||
});
|
||||
const getUnreadList = (type: string) => {
|
||||
const list = messageData.messageList.filter(
|
||||
(item) => item.type === type && !item.status
|
||||
);
|
||||
return list;
|
||||
};
|
||||
const formatUnreadLength = (type: string) => {
|
||||
const list = getUnreadList(type);
|
||||
return list.length ? `(${list.length})` : ``;
|
||||
};
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items]);
|
||||
};
|
||||
const emptyList = () => {
|
||||
messageData.messageList = [];
|
||||
};
|
||||
fetchSourceData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-list-item-meta) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 14px 0 12px 16px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
.arco-result-subtitle {
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
frontend/src/components/message-box/list.vue
Normal file
149
frontend/src/components/message-box/list.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="item.type === 'message'"
|
||||
class="time-text"
|
||||
>
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space
|
||||
fill
|
||||
:size="0"
|
||||
:class="{ 'add-border-top': renderList.length < showMax }"
|
||||
>
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { MessageRecord, MessageListType } from '@/api/message';
|
||||
|
||||
const props = defineProps({
|
||||
renderList: {
|
||||
type: Array as PropType<MessageListType>,
|
||||
required: true,
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['itemClick']);
|
||||
const allRead = () => {
|
||||
emit('itemClick', [...props.renderList]);
|
||||
};
|
||||
|
||||
const onItemClick = (item: MessageRecord) => {
|
||||
if (!item.status) {
|
||||
emit('itemClick', [item]);
|
||||
}
|
||||
};
|
||||
const showMax = 3;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-list) {
|
||||
.arco-list-item {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
.arco-list-item-extra {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
.arco-list-item-meta-content {
|
||||
flex: 1;
|
||||
}
|
||||
.item-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
.arco-empty {
|
||||
display: none;
|
||||
}
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-top: none;
|
||||
.arco-space-item {
|
||||
width: 100%;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
.add-border-top {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.add-border {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/components/message-box/locale/en-US.ts
Normal file
13
frontend/src/components/message-box/locale/en-US.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': 'Message',
|
||||
'messageBox.tab.title.notice': 'Notice',
|
||||
'messageBox.tab.title.todo': 'Todo',
|
||||
'messageBox.tab.button': 'empty',
|
||||
'messageBox.allRead': 'All Read',
|
||||
'messageBox.viewMore': 'View More',
|
||||
'messageBox.noContent': 'No Content',
|
||||
'messageBox.switchRoles': 'Switch Roles',
|
||||
'messageBox.userCenter': 'User Center',
|
||||
'messageBox.userSettings': 'User Settings',
|
||||
'messageBox.logout': 'Logout',
|
||||
};
|
||||
13
frontend/src/components/message-box/locale/zh-CN.ts
Normal file
13
frontend/src/components/message-box/locale/zh-CN.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': '消息',
|
||||
'messageBox.tab.title.notice': '通知',
|
||||
'messageBox.tab.title.todo': '待办',
|
||||
'messageBox.tab.button': '清空',
|
||||
'messageBox.allRead': '全部已读',
|
||||
'messageBox.viewMore': '查看更多',
|
||||
'messageBox.noContent': '暂无内容',
|
||||
'messageBox.switchRoles': '切换角色',
|
||||
'messageBox.userCenter': '用户中心',
|
||||
'messageBox.userSettings': '用户设置',
|
||||
'messageBox.logout': '登出登录',
|
||||
};
|
||||
323
frontend/src/components/navbar/index.vue
Normal file
323
frontend/src/components/navbar/index.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<div class="left-side">
|
||||
<a-space>
|
||||
<img
|
||||
alt="logo"
|
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
|
||||
/>
|
||||
<a-typography-title
|
||||
:style="{ margin: 0, fontSize: '18px' }"
|
||||
:heading="5"
|
||||
>
|
||||
DoLuEdu Template
|
||||
</a-typography-title>
|
||||
<icon-menu-fold
|
||||
v-if="!topMenu && appStore.device === 'mobile'"
|
||||
style="font-size: 22px; cursor: pointer"
|
||||
@click="toggleDrawerMenu"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="center-side">
|
||||
<Menu v-if="topMenu" />
|
||||
</div>
|
||||
<ul class="right-side">
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.search')">
|
||||
<a-button class="nav-btn" type="outline" :shape="'circle'">
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.language')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setDropDownVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-language />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-dropdown trigger="click" @select="changeLocale as any">
|
||||
<div ref="triggerBtn" class="trigger-btn"></div>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="item in locales"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-check v-show="item.value === currentLocale" />
|
||||
</template>
|
||||
{{ item.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
theme === 'light'
|
||||
? $t('settings.navbar.theme.toDark')
|
||||
: $t('settings.navbar.theme.toLight')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="handleToggleTheme"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-moon-fill v-if="theme === 'dark'" />
|
||||
<icon-sun-fill v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.navbar.alerts')">
|
||||
<div class="message-box-trigger">
|
||||
<a-badge :count="9" dot>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setPopoverVisible"
|
||||
>
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-popover
|
||||
trigger="click"
|
||||
:arrow-style="{ display: 'none' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px' }"
|
||||
content-class="message-popover"
|
||||
>
|
||||
<div ref="refBtn" class="ref-btn"></div>
|
||||
<template #content>
|
||||
<message-box />
|
||||
</template>
|
||||
</a-popover>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
isFullscreen
|
||||
? $t('settings.navbar.screen.toExit')
|
||||
: $t('settings.navbar.screen.toFull')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="toggleFullScreen"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-fullscreen-exit v-if="isFullscreen" />
|
||||
<icon-fullscreen v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.title')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-dropdown trigger="click">
|
||||
<div class="user-info-wrapper">
|
||||
<a-avatar
|
||||
:size="32"
|
||||
:style="{ cursor: 'pointer', backgroundColor: 'rgb(var(--primary-6))' }"
|
||||
>
|
||||
<icon-user />
|
||||
</a-avatar>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ userStore.name || '用户' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption>
|
||||
<a-space @click="handleLogout">
|
||||
<icon-export />
|
||||
<span>
|
||||
{{ $t('messageBox.logout') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, inject } from 'vue';
|
||||
import { useDark, useToggle, useFullscreen } from '@vueuse/core';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import { LOCALE_OPTIONS } from '@/locale';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import useUser from '@/hooks/user';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import MessageBox from '../message-box/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const { logout } = useUser();
|
||||
const { changeLocale, currentLocale } = useLocale();
|
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
|
||||
const locales = [...LOCALE_OPTIONS];
|
||||
const theme = computed(() => {
|
||||
return appStore.theme;
|
||||
});
|
||||
const topMenu = computed(() => appStore.topMenu && appStore.menu);
|
||||
const isDark = useDark({
|
||||
selector: 'body',
|
||||
attribute: 'arco-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
storageKey: 'arco-theme',
|
||||
onChanged(dark: boolean) {
|
||||
// overridden default behavior
|
||||
appStore.toggleTheme(dark);
|
||||
},
|
||||
});
|
||||
const toggleTheme = useToggle(isDark);
|
||||
const handleToggleTheme = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
const setVisible = () => {
|
||||
appStore.updateSettings({ globalSettings: true });
|
||||
};
|
||||
const refBtn = ref();
|
||||
const triggerBtn = ref();
|
||||
const setPopoverVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
refBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const setDropDownVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
triggerBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.left-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.center-side {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
display: flex;
|
||||
padding-right: 20px;
|
||||
list-style: none;
|
||||
:deep(.locale-select) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-btn {
|
||||
border-color: rgb(var(--gray-2));
|
||||
color: rgb(var(--gray-8));
|
||||
font-size: 16px;
|
||||
}
|
||||
.trigger-btn,
|
||||
.ref-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
}
|
||||
.trigger-btn {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.user-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-fill-2);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.message-popover {
|
||||
.arco-popover-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
frontend/src/components/password-change-modal/index.vue
Normal file
146
frontend/src/components/password-change-modal/index.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="$t('passwordChange.title')"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:footer="false"
|
||||
:width="400"
|
||||
>
|
||||
<div class="password-change-modal">
|
||||
<a-alert type="warning" class="mb-4">
|
||||
{{ $t('passwordChange.warning') }}
|
||||
</a-alert>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<a-form-item field="oldPassword" :label="$t('passwordChange.oldPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.oldPassword"
|
||||
:placeholder="$t('passwordChange.oldPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="newPassword" :label="$t('passwordChange.newPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.newPassword"
|
||||
:placeholder="$t('passwordChange.newPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="confirmPassword" :label="$t('passwordChange.confirmPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.confirmPassword"
|
||||
:placeholder="$t('passwordChange.confirmPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
:loading="loading"
|
||||
>
|
||||
{{ $t('passwordChange.submit') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { changePassword } from '@/api/auth';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
oldPassword: [
|
||||
{ required: true, message: t('passwordChange.oldPasswordRequired') },
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: t('passwordChange.newPasswordRequired') },
|
||||
{ minLength: 6, message: t('passwordChange.passwordMinLength') },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('passwordChange.confirmPasswordRequired') },
|
||||
{
|
||||
validator: (value: string, callback: (error?: string) => void) => {
|
||||
if (value !== formData.newPassword) {
|
||||
callback(t('passwordChange.passwordMismatch'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handleSubmit = async ({ errors }: { errors: Record<string, any> | undefined }) => {
|
||||
if (errors) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await changePassword({
|
||||
oldPassword: formData.oldPassword,
|
||||
newPassword: formData.newPassword,
|
||||
});
|
||||
|
||||
Message.success(t('passwordChange.success'));
|
||||
|
||||
userStore.setInfo({ passwordChanged: true });
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
} catch (err: any) {
|
||||
Message.error(err?.response?.data?.msg || t('passwordChange.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.password-change-modal {
|
||||
padding: 8px 0;
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/components/tab-bar/index.vue
Normal file
101
frontend/src/components/tab-bar/index.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="tab-bar-container">
|
||||
<a-affix ref="affixRef" :offset-top="offsetTop">
|
||||
<div class="tab-bar-box">
|
||||
<div class="tab-bar-scroll">
|
||||
<div class="tags-wrap">
|
||||
<tab-item
|
||||
v-for="(tag, index) in tagList"
|
||||
:key="tag.fullPath"
|
||||
:index="index"
|
||||
:item-data="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-bar-operation"></div>
|
||||
</div>
|
||||
</a-affix>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import {
|
||||
listenerRouteChange,
|
||||
removeRouteListener,
|
||||
} from '@/utils/route-listener';
|
||||
import { useAppStore, useTabBarStore } from '@/store';
|
||||
import tabItem from './tab-item.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const affixRef = ref();
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
const offsetTop = computed(() => {
|
||||
return appStore.navbar ? 60 : 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => appStore.navbar,
|
||||
() => {
|
||||
affixRef.value.updatePosition();
|
||||
}
|
||||
);
|
||||
listenerRouteChange((route: RouteLocationNormalized) => {
|
||||
if (
|
||||
!route.meta.noAffix &&
|
||||
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
|
||||
) {
|
||||
tabBarStore.updateTabList(route);
|
||||
}
|
||||
}, true);
|
||||
|
||||
onUnmounted(() => {
|
||||
removeRouteListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-bar-container {
|
||||
position: relative;
|
||||
background-color: var(--color-bg-2);
|
||||
.tab-bar-box {
|
||||
display: flex;
|
||||
padding: 0 0 0 20px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.tab-bar-scroll {
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.tags-wrap {
|
||||
padding: 4px 0;
|
||||
height: 48px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.arco-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
&:first-child {
|
||||
.arco-tag-close-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-bar-operation {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/components/tab-bar/readme.md
Normal file
12
frontend/src/components/tab-bar/readme.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## 组件说明
|
||||
|
||||
该组件非官方最终设计规范,以单独组件存在。
|
||||
|
||||
同时仅仅提供最基本的功能,后续进行优化及更改。
|
||||
|
||||
|
||||
## Component description
|
||||
|
||||
The component unofficial final design specification exists as a separate component.
|
||||
|
||||
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.
|
||||
200
frontend/src/components/tab-bar/tab-item.vue
Normal file
200
frontend/src/components/tab-bar/tab-item.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<a-dropdown
|
||||
trigger="contextMenu"
|
||||
:popup-max-height="false"
|
||||
@select="actionSelect"
|
||||
>
|
||||
<span
|
||||
class="arco-tag arco-tag-size-medium arco-tag-checked"
|
||||
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
|
||||
@click="goto(itemData)"
|
||||
>
|
||||
<span class="tag-link">
|
||||
{{ $t(itemData.title) }}
|
||||
</span>
|
||||
<span
|
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
|
||||
@click.stop="tagClose(itemData, index)"
|
||||
>
|
||||
<icon-close />
|
||||
</span>
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption :disabled="disabledReload" :value="Eaction.reload">
|
||||
<icon-refresh />
|
||||
<span>重新加载</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledCurrent"
|
||||
:value="Eaction.current"
|
||||
>
|
||||
<icon-close />
|
||||
<span>关闭当前标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :disabled="disabledLeft" :value="Eaction.left">
|
||||
<icon-to-left />
|
||||
<span>关闭左侧标签页</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledRight"
|
||||
:value="Eaction.right"
|
||||
>
|
||||
<icon-to-right />
|
||||
<span>关闭右侧标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :value="Eaction.others">
|
||||
<icon-swap />
|
||||
<span>关闭其它标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :value="Eaction.all">
|
||||
<icon-folder-delete />
|
||||
<span>关闭全部标签页</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTabBarStore } from '@/store';
|
||||
import type { TagProps } from '@/store/modules/tab-bar/types';
|
||||
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
enum Eaction {
|
||||
reload = 'reload',
|
||||
current = 'current',
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
others = 'others',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
itemData: {
|
||||
type: Object as PropType<TagProps>,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const goto = (tag: TagProps) => {
|
||||
router.push({ ...tag });
|
||||
};
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
|
||||
const disabledReload = computed(() => {
|
||||
return props.itemData.fullPath !== route.fullPath;
|
||||
});
|
||||
|
||||
const disabledCurrent = computed(() => {
|
||||
return props.index === 0;
|
||||
});
|
||||
|
||||
const disabledLeft = computed(() => {
|
||||
return [0, 1].includes(props.index);
|
||||
});
|
||||
|
||||
const disabledRight = computed(() => {
|
||||
return props.index === tagList.value.length - 1;
|
||||
});
|
||||
|
||||
const tagClose = (tag: TagProps, idx: number) => {
|
||||
tabBarStore.deleteTag(idx, tag);
|
||||
if (props.itemData.fullPath === route.fullPath) {
|
||||
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
|
||||
router.push({ name: latest.name });
|
||||
}
|
||||
};
|
||||
|
||||
const findCurrentRouteIndex = () => {
|
||||
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
|
||||
};
|
||||
const actionSelect = async (value: any) => {
|
||||
const { itemData, index } = props;
|
||||
const copyTagList = [...tagList.value];
|
||||
if (value === Eaction.current) {
|
||||
tagClose(itemData, index);
|
||||
} else if (value === Eaction.left) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(1, props.index - 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx < index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === Eaction.right) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(props.index + 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx > index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === Eaction.others) {
|
||||
const filterList = tagList.value.filter((el, idx) => {
|
||||
return idx === 0 || idx === props.index;
|
||||
});
|
||||
tabBarStore.freshTabList(filterList);
|
||||
router.push({ name: itemData.name });
|
||||
} else if (value === Eaction.reload) {
|
||||
tabBarStore.deleteCache(itemData);
|
||||
await router.push({
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
params: {
|
||||
path: route.fullPath,
|
||||
},
|
||||
});
|
||||
tabBarStore.addCache(itemData.name);
|
||||
} else {
|
||||
tabBarStore.resetTabList();
|
||||
router.push({ name: DEFAULT_ROUTE_NAME });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tag-link {
|
||||
color: var(--color-text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-activated {
|
||||
color: rgb(var(--link-6));
|
||||
.tag-link {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
& + .arco-tag-close-btn {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
}
|
||||
:deep(.arco-dropdown-option-content) {
|
||||
span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.arco-dropdown-open {
|
||||
.tag-link {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
.arco-tag-close-btn {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
.sperate-line {
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/config/settings.json
Normal file
17
frontend/src/config/settings.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"theme": "light",
|
||||
"colorWeak": false,
|
||||
"navbar": true,
|
||||
"menu": true,
|
||||
"topMenu": false,
|
||||
"hideMenu": false,
|
||||
"menuCollapse": false,
|
||||
"footer": true,
|
||||
"themeColor": "#165DFF",
|
||||
"menuWidth": 220,
|
||||
"globalSettings": false,
|
||||
"device": "desktop",
|
||||
"tabBar": false,
|
||||
"menuFromServer": false,
|
||||
"serverMenu": []
|
||||
}
|
||||
8
frontend/src/directive/index.ts
Normal file
8
frontend/src/directive/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { App } from 'vue';
|
||||
import permission from './permission';
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.directive('permission', permission);
|
||||
},
|
||||
};
|
||||
30
frontend/src/directive/permission/index.ts
Normal file
30
frontend/src/directive/permission/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { value } = binding;
|
||||
const userStore = useUserStore();
|
||||
const { role } = userStore;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
const permissionValues = value;
|
||||
|
||||
const hasPermission = permissionValues.includes(role);
|
||||
if (!hasPermission && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
23
frontend/src/env.d.ts
vendored
Normal file
23
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_TRACKING_APP_KEY?: string;
|
||||
readonly VITE_TRACKING_REPORT_URL?: string;
|
||||
readonly VITE_TRACKING_SAMPLE_RATE?: string;
|
||||
readonly VITE_TRACKING_MAX_BATCH_SIZE?: string;
|
||||
readonly VITE_TRACKING_FLUSH_INTERVAL?: string;
|
||||
readonly VITE_TRACKING_CUSTOM_FLUSH_INTERVAL?: string;
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
[elem: string]: any;
|
||||
}
|
||||
}
|
||||
27
frontend/src/hooks/chart-option.ts
Normal file
27
frontend/src/hooks/chart-option.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { computed } from 'vue';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
// for code hints
|
||||
// import { SeriesOption } from 'echarts';
|
||||
// Because there are so many configuration items, this provides a relatively convenient code hint.
|
||||
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
|
||||
interface optionsFn {
|
||||
(isDark: boolean): EChartsOption;
|
||||
}
|
||||
|
||||
export default function useChartOption(sourceOption: optionsFn) {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
// echarts support https://echarts.apache.org/zh/theme-builder.html
|
||||
// It's not used here
|
||||
// TODO echarts themes
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
return sourceOption(isDark.value);
|
||||
});
|
||||
return {
|
||||
chartOption,
|
||||
};
|
||||
}
|
||||
16
frontend/src/hooks/loading.ts
Normal file
16
frontend/src/hooks/loading.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default function useLoading(initValue = false) {
|
||||
const loading = ref(initValue);
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value;
|
||||
};
|
||||
const toggle = () => {
|
||||
loading.value = !loading.value;
|
||||
};
|
||||
return {
|
||||
loading,
|
||||
setLoading,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
22
frontend/src/hooks/locale.ts
Normal file
22
frontend/src/hooks/locale.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
export default function useLocale() {
|
||||
const i18 = useI18n();
|
||||
const currentLocale = computed(() => {
|
||||
return i18.locale.value;
|
||||
});
|
||||
const changeLocale = (value: string) => {
|
||||
if (i18.locale.value === value) {
|
||||
return;
|
||||
}
|
||||
i18.locale.value = value;
|
||||
localStorage.setItem('arco-locale', value);
|
||||
Message.success(i18.t('navbar.action.locale'));
|
||||
};
|
||||
return {
|
||||
currentLocale,
|
||||
changeLocale,
|
||||
};
|
||||
}
|
||||
42
frontend/src/hooks/permission.ts
Normal file
42
frontend/src/hooks/permission.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
return {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
if (!route.meta?.requiresAuth) {
|
||||
return true;
|
||||
}
|
||||
if (!route.meta?.roles) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes(userStore.role)) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes('system_admin') && userStore.isSystemAdmin) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
const cloneRouters = [..._routers];
|
||||
while (cloneRouters.length) {
|
||||
const firstElement = cloneRouters.shift();
|
||||
if (
|
||||
firstElement?.meta?.roles?.find((el: string[]) => {
|
||||
return el.includes('*') || el.includes(role);
|
||||
})
|
||||
)
|
||||
return { name: firstElement.name };
|
||||
if (firstElement?.children) {
|
||||
cloneRouters.push(...firstElement.children);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
26
frontend/src/hooks/request.ts
Normal file
26
frontend/src/hooks/request.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ref, UnwrapRef } from 'vue';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { HttpResponse } from '@/api/interceptor';
|
||||
import useLoading from './loading';
|
||||
|
||||
// use to fetch list
|
||||
// Don't use async function. It doesn't work in async function.
|
||||
// Use the bind function to add parameters
|
||||
// example: useRequest(api.bind(null, {}))
|
||||
|
||||
export default function useRequest<T>(
|
||||
api: () => Promise<AxiosResponse<HttpResponse>>,
|
||||
defaultValue = [] as unknown as T,
|
||||
isLoading = true
|
||||
) {
|
||||
const { loading, setLoading } = useLoading(isLoading);
|
||||
const response = ref<T>(defaultValue);
|
||||
api()
|
||||
.then((res) => {
|
||||
response.value = res.data as unknown as UnwrapRef<T>;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
return { loading, response };
|
||||
}
|
||||
32
frontend/src/hooks/responsive.ts
Normal file
32
frontend/src/hooks/responsive.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { onMounted, onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useAppStore } from '@/store';
|
||||
import { addEventListen, removeEventListen } from '@/utils/event';
|
||||
|
||||
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
|
||||
|
||||
function queryDevice() {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
return rect.width - 1 < WIDTH;
|
||||
}
|
||||
|
||||
export default function useResponsive(immediate?: boolean) {
|
||||
const appStore = useAppStore();
|
||||
function resizeHandler() {
|
||||
if (!document.hidden) {
|
||||
const isMobile = queryDevice();
|
||||
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
|
||||
appStore.toggleMenu(isMobile);
|
||||
}
|
||||
}
|
||||
const debounceFn = useDebounceFn(resizeHandler, 100);
|
||||
onMounted(() => {
|
||||
if (immediate) debounceFn();
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
addEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
}
|
||||
12
frontend/src/hooks/themes.ts
Normal file
12
frontend/src/hooks/themes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export default function useThemes() {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
return {
|
||||
isDark,
|
||||
};
|
||||
}
|
||||
24
frontend/src/hooks/user.ts
Normal file
24
frontend/src/hooks/user.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function useUser() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const logout = async (logoutTo?: string) => {
|
||||
await userStore.logout();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Message.success('登出成功');
|
||||
router.push({
|
||||
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
redirect: currentRoute.name as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
logout,
|
||||
};
|
||||
}
|
||||
16
frontend/src/hooks/visible.ts
Normal file
16
frontend/src/hooks/visible.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default function useVisible(initValue = false) {
|
||||
const visible = ref(initValue);
|
||||
const setVisible = (value: boolean) => {
|
||||
visible.value = value;
|
||||
};
|
||||
const toggle = () => {
|
||||
visible.value = !visible.value;
|
||||
};
|
||||
return {
|
||||
visible,
|
||||
setVisible,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
180
frontend/src/layout/default-layout.vue
Normal file
180
frontend/src/layout/default-layout.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
|
||||
<div v-if="navbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout>
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
v-if="renderMenu"
|
||||
v-show="!hideMenu"
|
||||
class="layout-sider"
|
||||
breakpoint="xl"
|
||||
:collapsed="collapsed"
|
||||
:collapsible="true"
|
||||
:width="menuWidth"
|
||||
:style="{ paddingTop: navbar ? '60px' : '' }"
|
||||
:hide-trigger="true"
|
||||
@collapse="setCollapsed"
|
||||
>
|
||||
<div class="menu-wrapper">
|
||||
<Menu />
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-drawer
|
||||
v-if="hideMenu"
|
||||
:visible="drawerVisible"
|
||||
placement="left"
|
||||
:footer="false"
|
||||
mask-closable
|
||||
:closable="false"
|
||||
@cancel="drawerCancel"
|
||||
>
|
||||
<Menu />
|
||||
</a-drawer>
|
||||
<a-layout class="layout-content" :style="paddingStyle">
|
||||
<TabBar v-if="appStore.tabBar" />
|
||||
<a-layout-content>
|
||||
<PageLayout />
|
||||
</a-layout-content>
|
||||
<Footer v-if="footer" />
|
||||
<FloatingActions />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, provide, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import Footer from '@/components/footer/index.vue';
|
||||
import TabBar from '@/components/tab-bar/index.vue';
|
||||
import FloatingActions from '@/components/floating-actions/index.vue';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import useResponsive from '@/hooks/responsive';
|
||||
import PageLayout from './page-layout.vue';
|
||||
|
||||
const isInit = ref(false);
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const permission = usePermission();
|
||||
useResponsive(true);
|
||||
const navbarHeight = `60px`;
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
|
||||
const hideMenu = computed(() => appStore.hideMenu);
|
||||
const footer = computed(() => appStore.footer);
|
||||
const menuWidth = computed(() => {
|
||||
return appStore.menuCollapse ? 48 : appStore.menuWidth;
|
||||
});
|
||||
const collapsed = computed(() => {
|
||||
return appStore.menuCollapse;
|
||||
});
|
||||
const paddingStyle = computed(() => {
|
||||
const paddingLeft =
|
||||
renderMenu.value && !hideMenu.value
|
||||
? { paddingLeft: `${menuWidth.value}px` }
|
||||
: {};
|
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
|
||||
return { ...paddingLeft, ...paddingTop };
|
||||
});
|
||||
const setCollapsed = (val: boolean) => {
|
||||
if (!isInit.value) return; // for page initialization menu state problem
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
watch(
|
||||
() => userStore.role,
|
||||
(roleValue) => {
|
||||
if (roleValue && !permission.accessRouter(route))
|
||||
router.push({ name: 'notFound' });
|
||||
}
|
||||
);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerCancel = () => {
|
||||
drawerVisible.value = false;
|
||||
};
|
||||
provide('toggleDrawerMenu', () => {
|
||||
drawerVisible.value = !drawerVisible.value;
|
||||
});
|
||||
onMounted(() => {
|
||||
isInit.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@nav-size-height: 60px;
|
||||
@layout-max-width: 1100px;
|
||||
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: @nav-size-height;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
height: 100%;
|
||||
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--color-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
> :deep(.arco-layout-sider-children) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
:deep(.arco-menu) {
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-text-4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
min-height: 100vh;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--color-fill-2);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/layout/fullscreen-layout.vue
Normal file
67
frontend/src/layout/fullscreen-layout.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<a-layout class="fullscreen-layout">
|
||||
<div v-if="showNavbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout-content class="layout-content" :style="contentStyle">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const navbarHeight = '60px';
|
||||
|
||||
// 如果路由 meta 中设置了 hideNavbar: true,则不显示公共 NavBar
|
||||
const showNavbar = computed(() => {
|
||||
return navbar.value && !route.meta?.hideNavbar;
|
||||
});
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
return showNavbar.value ? { paddingTop: navbarHeight } : {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.fullscreen-layout {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
background-color: var(--color-bg-1);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/layout/page-layout.vue
Normal file
25
frontend/src/layout/page-layout.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else :include="cacheList">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTabBarStore } from '@/store';
|
||||
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const cacheList = computed(() => tabBarStore.getCacheList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
23
frontend/src/locale/en-US.ts
Normal file
23
frontend/src/locale/en-US.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import localeMessageBox from '@/components/message-box/locale/en-US';
|
||||
import localeLogin from '@/views/login/locale/en-US';
|
||||
import localeSettings from './en-US/settings';
|
||||
|
||||
export default {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.dashboard.workplace': 'Workplace',
|
||||
'menu.book': 'Books',
|
||||
'menu.book.list': 'Book List',
|
||||
'menu.book.detail': 'Book Detail',
|
||||
'menu.system': 'System',
|
||||
'menu.system.users': 'Users',
|
||||
'menu.system.roles': 'Roles',
|
||||
'menu.exception': 'Exception',
|
||||
'menu.exception.403': 'Forbidden',
|
||||
'menu.exception.404': 'Not Found',
|
||||
'menu.exception.500': 'Server Error',
|
||||
'navbar.docs': 'Docs',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeSettings,
|
||||
...localeMessageBox,
|
||||
...localeLogin,
|
||||
};
|
||||
29
frontend/src/locale/en-US/settings.ts
Normal file
29
frontend/src/locale/en-US/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
'settings.title': 'Settings',
|
||||
'settings.themeColor': 'Theme Color',
|
||||
'settings.content': 'Content Setting',
|
||||
'settings.search': 'Search',
|
||||
'settings.language': 'Language',
|
||||
'settings.navbar': 'Navbar',
|
||||
'settings.menuWidth': 'Menu Width (px)',
|
||||
'settings.navbar.theme.toLight': 'Click to use light mode',
|
||||
'settings.navbar.theme.toDark': 'Click to use dark mode',
|
||||
'settings.navbar.screen.toFull': 'Click to switch to full screen mode',
|
||||
'settings.navbar.screen.toExit': 'Click to exit the full screen mode',
|
||||
'settings.navbar.alerts': 'alerts',
|
||||
'settings.menu': 'Menu',
|
||||
'settings.topMenu': 'Top Menu',
|
||||
'settings.tabBar': 'Tab Bar',
|
||||
'settings.footer': 'Footer',
|
||||
'settings.otherSettings': 'Other Settings',
|
||||
'settings.colorWeak': 'Color Weak',
|
||||
'settings.alertContent':
|
||||
'After the configuration is only temporarily effective, if you want to really affect the project, click the "Copy Settings" button below and replace the configuration in settings.json.',
|
||||
'settings.copySettings': 'Copy Settings',
|
||||
'settings.copySettings.message':
|
||||
'Copy succeeded, please paste to file src/settings.json.',
|
||||
'settings.close': 'Close',
|
||||
'settings.color.tooltip':
|
||||
'10 gradient colors generated according to the theme color',
|
||||
'settings.menuFromServer': 'Menu From Server',
|
||||
};
|
||||
22
frontend/src/locale/index.ts
Normal file
22
frontend/src/locale/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
legacy: false,
|
||||
allowComposition: true,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
43
frontend/src/locale/zh-CN.ts
Normal file
43
frontend/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import localeMessageBox from '@/components/message-box/locale/zh-CN';
|
||||
import localeLogin from '@/views/login/locale/zh-CN';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const localePasswordChange = {
|
||||
'passwordChange.title': '修改密码',
|
||||
'passwordChange.warning': '为了您的账号安全,首次登录请修改默认密码。',
|
||||
'passwordChange.oldPassword': '原密码',
|
||||
'passwordChange.oldPasswordPlaceholder': '请输入原密码',
|
||||
'passwordChange.oldPasswordRequired': '请输入原密码',
|
||||
'passwordChange.newPassword': '新密码',
|
||||
'passwordChange.newPasswordPlaceholder': '请输入新密码',
|
||||
'passwordChange.newPasswordRequired': '请输入新密码',
|
||||
'passwordChange.passwordMinLength': '密码长度不能少于6位',
|
||||
'passwordChange.confirmPassword': '确认密码',
|
||||
'passwordChange.confirmPasswordPlaceholder': '请再次输入新密码',
|
||||
'passwordChange.confirmPasswordRequired': '请再次输入新密码',
|
||||
'passwordChange.passwordMismatch': '两次输入的密码不一致',
|
||||
'passwordChange.submit': '确认修改',
|
||||
'passwordChange.success': '密码修改成功',
|
||||
'passwordChange.error': '密码修改失败',
|
||||
};
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.dashboard.workplace': '工作台',
|
||||
'menu.book': '教辅管理',
|
||||
'menu.book.list': '教辅列表',
|
||||
'menu.book.detail': '教辅详情',
|
||||
'menu.system': '系统管理',
|
||||
'menu.system.users': '用户管理',
|
||||
'menu.system.roles': '角色管理',
|
||||
'menu.exception': '异常页',
|
||||
'menu.exception.403': '无权限',
|
||||
'menu.exception.404': '未找到',
|
||||
'menu.exception.500': '服务错误',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...localeMessageBox,
|
||||
...localeLogin,
|
||||
...localePasswordChange,
|
||||
};
|
||||
29
frontend/src/locale/zh-CN/settings.ts
Normal file
29
frontend/src/locale/zh-CN/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
'settings.title': '页面配置',
|
||||
'settings.themeColor': '主题色',
|
||||
'settings.content': '内容区域',
|
||||
'settings.search': '搜索',
|
||||
'settings.language': '语言',
|
||||
'settings.navbar': '导航栏',
|
||||
'settings.menuWidth': '菜单宽度 (px)',
|
||||
'settings.navbar.theme.toLight': '点击切换为亮色模式',
|
||||
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
|
||||
'settings.navbar.screen.toFull': '点击切换全屏模式',
|
||||
'settings.navbar.screen.toExit': '点击退出全屏模式',
|
||||
'settings.navbar.alerts': '消息通知',
|
||||
'settings.menu': '菜单栏',
|
||||
'settings.topMenu': '顶部菜单栏',
|
||||
'settings.tabBar': '多页签',
|
||||
'settings.footer': '底部',
|
||||
'settings.otherSettings': '其他设置',
|
||||
'settings.colorWeak': '色弱模式',
|
||||
'settings.alertContent':
|
||||
'配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。',
|
||||
'settings.copySettings': '复制配置',
|
||||
'settings.copySettings.message':
|
||||
'复制成功,请粘贴到 src/settings.json 文件中',
|
||||
'settings.close': '关闭',
|
||||
'settings.color.tooltip':
|
||||
'根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)',
|
||||
'settings.menuFromServer': '菜单来源于后台',
|
||||
};
|
||||
33
frontend/src/main.ts
Normal file
33
frontend/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue';
|
||||
import ArcoVue from '@arco-design/web-vue';
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import globalComponents from '@/components';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import i18n from './locale';
|
||||
import directive from './directive';
|
||||
// import './mock'; // 禁用 mock,使用真实后端 API
|
||||
import App from './App.vue';
|
||||
// Styles are imported via arco-plugin. See config/plugin/arcoStyleImport.ts in the directory for details
|
||||
// 样式通过 arco-plugin 插件导入。详见目录文件 config/plugin/arcoStyleImport.ts
|
||||
// https://arco.design/docs/designlab/use-theme-package
|
||||
import '@/assets/style/global.less';
|
||||
import '@/api/interceptor';
|
||||
import { setupFrontendTracking, trackPageView } from '@/utils/monitor';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ArcoVue, {});
|
||||
app.use(ArcoVueIcon);
|
||||
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(globalComponents);
|
||||
app.use(directive);
|
||||
setupFrontendTracking(app);
|
||||
router.afterEach((to) => {
|
||||
trackPageView(to.fullPath, { title: document.title });
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
16
frontend/src/router/app-menus/index.ts
Normal file
16
frontend/src/router/app-menus/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { appRoutes, appExternalRoutes } from '../routes';
|
||||
|
||||
const mixinRoutes = [...appRoutes, ...appExternalRoutes];
|
||||
|
||||
const appClientMenus = mixinRoutes.map((el) => {
|
||||
const { name, path, meta, redirect, children } = el;
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
meta,
|
||||
redirect,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
export default appClientMenus;
|
||||
18
frontend/src/router/constants.ts
Normal file
18
frontend/src/router/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const WHITE_LIST = [
|
||||
{ name: 'notFound', children: [] },
|
||||
{ name: 'login', children: [] },
|
||||
];
|
||||
|
||||
export const NOT_FOUND = {
|
||||
name: 'notFound',
|
||||
};
|
||||
|
||||
export const REDIRECT_ROUTE_NAME = 'Redirect';
|
||||
|
||||
export const DEFAULT_ROUTE_NAME = 'Workplace';
|
||||
|
||||
export const DEFAULT_ROUTE = {
|
||||
title: 'menu.dashboard.workplace',
|
||||
name: DEFAULT_ROUTE_NAME,
|
||||
fullPath: '/dashboard/workplace',
|
||||
};
|
||||
26
frontend/src/router/guard/index.ts
Normal file
26
frontend/src/router/guard/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Router } from 'vue-router';
|
||||
import NProgress from 'nprogress';
|
||||
import { setRouteEmitter } from '@/utils/route-listener';
|
||||
import setupUserLoginInfoGuard from './userLoginInfo';
|
||||
import setupPermissionGuard from './permission';
|
||||
|
||||
function setupPageGuard(router: Router) {
|
||||
router.beforeEach(async (to) => {
|
||||
// emit route change
|
||||
setRouteEmitter(to);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure NProgress is always finished after navigation
|
||||
function setupProgressGuard(router: Router) {
|
||||
router.afterEach(() => {
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
|
||||
export default function createRouteGuard(router: Router) {
|
||||
setupPageGuard(router);
|
||||
setupUserLoginInfoGuard(router);
|
||||
setupPermissionGuard(router);
|
||||
setupProgressGuard(router);
|
||||
}
|
||||
73
frontend/src/router/guard/permission.ts
Normal file
73
frontend/src/router/guard/permission.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Router, RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useUserStore, useAppStore } from '@/store';
|
||||
import { appRoutes } from '../routes';
|
||||
import { WHITE_LIST, NOT_FOUND } from '../constants';
|
||||
|
||||
let routesRegistered = false;
|
||||
|
||||
const SYSTEM_ADMIN_ROUTES = ['SystemUserManage', 'SystemRoleManage'];
|
||||
|
||||
function checkRouteExist(menus: RouteRecordNormalized[], routeName: string | symbol | null | undefined): boolean {
|
||||
if (!routeName) return false;
|
||||
return menus.some((menu) => menu.name === routeName || checkRouteExist((menu.children || []) as RouteRecordNormalized[], routeName));
|
||||
}
|
||||
|
||||
function isSystemAdminRoute(routeName: string | symbol | null | undefined): boolean {
|
||||
if (!routeName || typeof routeName !== 'string') return false;
|
||||
return SYSTEM_ADMIN_ROUTES.some((prefix) => routeName.startsWith(prefix));
|
||||
}
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const Permission = usePermission();
|
||||
const permissionsAllow = Permission.accessRouter(to);
|
||||
|
||||
if (isSystemAdminRoute(to.name) && !userStore.isSystemAdmin) {
|
||||
next({ name: '403' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStore.menuFromServer) {
|
||||
const isInWhitelist = WHITE_LIST.find((el) => el.name === to.name);
|
||||
if (!appStore.appAsyncMenus.length && !isInWhitelist) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
|
||||
if (appStore.appAsyncMenus.length > 0 && !routesRegistered) {
|
||||
const registerRoutes = (routes: RouteRecordNormalized[]) => {
|
||||
routes.forEach((route) => {
|
||||
if (route.path?.startsWith('http://') || route.path?.startsWith('https://')) return;
|
||||
if (route.name && !router.hasRoute(route.name as string)) router.addRoute(route);
|
||||
if (route.children?.length) registerRoutes(route.children as RouteRecordNormalized[]);
|
||||
});
|
||||
};
|
||||
registerRoutes(appStore.appAsyncMenus);
|
||||
routesRegistered = true;
|
||||
next({ ...to, replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const exist = checkRouteExist(appStore.appAsyncMenus, to.name) || WHITE_LIST.some((el) => el.name === to.name);
|
||||
if (exist && permissionsAllow) {
|
||||
next();
|
||||
} else {
|
||||
next(NOT_FOUND);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissionsAllow) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
export function resetRouteRegistration() {
|
||||
routesRegistered = false;
|
||||
}
|
||||
43
frontend/src/router/guard/userLoginInfo.ts
Normal file
43
frontend/src/router/guard/userLoginInfo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Router, LocationQueryRaw } from 'vue-router';
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
|
||||
export default function setupUserLoginInfoGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
const userStore = useUserStore();
|
||||
if (isLogin()) {
|
||||
if (userStore.role) {
|
||||
next();
|
||||
} else {
|
||||
try {
|
||||
await userStore.info();
|
||||
next();
|
||||
} catch (error) {
|
||||
await userStore.logout();
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (to.name === 'login') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
37
frontend/src/router/index.ts
Normal file
37
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import { appRoutes } from './routes';
|
||||
import { REDIRECT_MAIN, NOT_FOUND_ROUTE } from './routes/base';
|
||||
import createRouteGuard from './guard';
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: 'login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Login/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
...appRoutes,
|
||||
REDIRECT_MAIN,
|
||||
NOT_FOUND_ROUTE,
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
createRouteGuard(router);
|
||||
|
||||
export default router;
|
||||
32
frontend/src/router/routes/base.ts
Normal file
32
frontend/src/router/routes/base.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
|
||||
export const FULLSCREEN_LAYOUT = () => import('@/layout/fullscreen-layout.vue');
|
||||
|
||||
export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||
path: '/redirect',
|
||||
name: 'redirectWrapper',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path',
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
component: () => import('@/views/Redirect/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notFound',
|
||||
component: () => import('@/views/Exception/404/index.vue'),
|
||||
};
|
||||
25
frontend/src/router/routes/index.ts
Normal file
25
frontend/src/router/routes/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
const modules = import.meta.glob('./modules/*.ts', { eager: true });
|
||||
const externalModules = import.meta.glob('./externalModules/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function formatModules(_modules: any, result: RouteRecordNormalized[]) {
|
||||
Object.keys(_modules).forEach((key) => {
|
||||
const defaultModule = _modules[key].default;
|
||||
if (!defaultModule) return;
|
||||
const moduleList = Array.isArray(defaultModule)
|
||||
? [...defaultModule]
|
||||
: [defaultModule];
|
||||
result.push(...moduleList);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []);
|
||||
|
||||
export const appExternalRoutes: RouteRecordNormalized[] = formatModules(
|
||||
externalModules,
|
||||
[]
|
||||
);
|
||||
41
frontend/src/router/routes/modules/book.ts
Normal file
41
frontend/src/router/routes/modules/book.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const BOOK: AppRouteRecordRaw = {
|
||||
path: '/book',
|
||||
name: 'BookManage',
|
||||
component: DEFAULT_LAYOUT,
|
||||
redirect: '/book/list',
|
||||
meta: {
|
||||
locale: 'menu.book',
|
||||
requiresAuth: false,
|
||||
icon: 'icon-book',
|
||||
order: 10,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'BookList',
|
||||
component: () => import('@/views/BookManage/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.book.list',
|
||||
requiresAuth: false,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'BookDetail',
|
||||
component: () => import('@/views/BookManage/BookDetail/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.book.detail',
|
||||
requiresAuth: false,
|
||||
roles: ['*'],
|
||||
hideInMenu: true,
|
||||
activeMenu: '/book/list',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default BOOK;
|
||||
29
frontend/src/router/routes/modules/dashboard.ts
Normal file
29
frontend/src/router/routes/modules/dashboard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const DASHBOARD: AppRouteRecordRaw = {
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: DEFAULT_LAYOUT,
|
||||
redirect: '/dashboard/workplace',
|
||||
meta: {
|
||||
locale: 'menu.dashboard',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-dashboard',
|
||||
order: 0,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'workplace',
|
||||
name: 'Workplace',
|
||||
component: () => import('@/views/Dashboard/workplace/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.dashboard.workplace',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default DASHBOARD;
|
||||
49
frontend/src/router/routes/modules/exception.ts
Normal file
49
frontend/src/router/routes/modules/exception.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const EXCEPTION: AppRouteRecordRaw = {
|
||||
path: '/exception',
|
||||
name: 'exception',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
locale: 'menu.exception',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-exclamation-circle',
|
||||
order: 99,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '403',
|
||||
name: '403',
|
||||
component: () => import('@/views/Exception/403/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.exception.403',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '404',
|
||||
name: '404',
|
||||
component: () => import('@/views/Exception/404/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.exception.404',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '500',
|
||||
name: '500',
|
||||
component: () => import('@/views/Exception/500/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.exception.500',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default EXCEPTION;
|
||||
39
frontend/src/router/routes/modules/system.ts
Normal file
39
frontend/src/router/routes/modules/system.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const SYSTEM: AppRouteRecordRaw = {
|
||||
path: '/system',
|
||||
name: 'System',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
locale: 'menu.system',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-settings',
|
||||
order: 1,
|
||||
roles: ['system_admin'],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'users',
|
||||
name: 'SystemUserManage',
|
||||
component: () => import('@/views/System/UserManage/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.system.users',
|
||||
requiresAuth: true,
|
||||
roles: ['system_admin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'SystemRoleManage',
|
||||
component: () => import('@/views/System/RoleManage/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.system.roles',
|
||||
requiresAuth: true,
|
||||
roles: ['system_admin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default SYSTEM;
|
||||
20
frontend/src/router/routes/types.ts
Normal file
20
frontend/src/router/routes/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import type { RouteMeta, NavigationGuard } from 'vue-router';
|
||||
|
||||
export type Component<T = any> =
|
||||
| ReturnType<typeof defineComponent>
|
||||
| (() => Promise<typeof import('*.vue')>)
|
||||
| (() => Promise<T>);
|
||||
|
||||
export interface AppRouteRecordRaw {
|
||||
path: string;
|
||||
name?: string | symbol;
|
||||
meta?: RouteMeta;
|
||||
redirect?: string;
|
||||
component: Component | string;
|
||||
children?: AppRouteRecordRaw[];
|
||||
alias?: string | string[];
|
||||
props?: Record<string, any>;
|
||||
beforeEnter?: NavigationGuard | NavigationGuard[];
|
||||
fullPath?: string;
|
||||
}
|
||||
16
frontend/src/router/typings.d.ts
vendored
Normal file
16
frontend/src/router/typings.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'vue-router';
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
roles?: string[]; // Controls roles that have access to the page
|
||||
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
|
||||
icon?: string; // The icon show in the side menu
|
||||
locale?: string; // The locale name show in side menu and breadcrumb
|
||||
hideInMenu?: boolean; // If true, it is not displayed in the side menu
|
||||
hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu
|
||||
activeMenu?: string; // if set name, the menu will be highlighted according to the name you set
|
||||
order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is
|
||||
noAffix?: boolean; // if set true, the tag will not affix in the tab-bar
|
||||
ignoreCache?: boolean; // if set true, the page will not be cached
|
||||
}
|
||||
}
|
||||
9
frontend/src/store/index.ts
Normal file
9
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import useAppStore from './modules/app';
|
||||
import useUserStore from './modules/user';
|
||||
import useTabBarStore from './modules/tab-bar';
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
export { useAppStore, useUserStore, useTabBarStore };
|
||||
export default pinia;
|
||||
136
frontend/src/store/modules/app/index.ts
Normal file
136
frontend/src/store/modules/app/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { Notification } from '@arco-design/web-vue';
|
||||
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import defaultSettings from '@/config/settings.json';
|
||||
import { getMenuList } from '@/api/user';
|
||||
import { DEFAULT_LAYOUT } from '@/router/routes/base';
|
||||
import { AppState } from './types';
|
||||
|
||||
const serverMenuComponentMap: Record<string, any> = {
|
||||
'Dashboard/workplace/index': () => import('@/views/Dashboard/workplace/index.vue'),
|
||||
'System/UserManage/index': () => import('@/views/System/UserManage/index.vue'),
|
||||
'System/RoleManage/index': () => import('@/views/System/RoleManage/index.vue'),
|
||||
};
|
||||
|
||||
const useAppStore = defineStore('app', {
|
||||
state: (): AppState => ({ ...defaultSettings }),
|
||||
|
||||
getters: {
|
||||
appCurrentSetting(state: AppState): AppState {
|
||||
return { ...state };
|
||||
},
|
||||
appDevice(state: AppState) {
|
||||
return state.device;
|
||||
},
|
||||
appAsyncMenus(state: AppState): RouteRecordNormalized[] {
|
||||
return state.serverMenu as unknown as RouteRecordNormalized[];
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateSettings(partial: Partial<AppState>) {
|
||||
// @ts-ignore-next-line
|
||||
this.$patch(partial);
|
||||
},
|
||||
toggleTheme(dark: boolean) {
|
||||
if (dark) {
|
||||
this.theme = 'dark';
|
||||
document.body.setAttribute('arco-theme', 'dark');
|
||||
} else {
|
||||
this.theme = 'light';
|
||||
document.body.removeAttribute('arco-theme');
|
||||
}
|
||||
},
|
||||
toggleDevice(device: string) {
|
||||
this.device = device;
|
||||
},
|
||||
toggleMenu(value: boolean) {
|
||||
this.hideMenu = value;
|
||||
},
|
||||
async fetchServerMenuConfig() {
|
||||
let notifyInstance: NotificationReturn | null = null;
|
||||
try {
|
||||
notifyInstance = Notification.info({
|
||||
id: 'menuNotice',
|
||||
content: '正在加载菜单...',
|
||||
closable: true,
|
||||
});
|
||||
|
||||
const response = await getMenuList();
|
||||
const menuData = Array.isArray((response as any)?.data)
|
||||
? (response as any).data
|
||||
: [];
|
||||
|
||||
this.serverMenu = this.convertServerMenusToRoutes(menuData);
|
||||
|
||||
notifyInstance = Notification.success({
|
||||
id: 'menuNotice',
|
||||
content: '菜单加载成功',
|
||||
closable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as any)?.response?.data?.msg ||
|
||||
(error as any)?.response?.data?.message ||
|
||||
(error as Error)?.message ||
|
||||
'菜单加载失败';
|
||||
notifyInstance = Notification.error({
|
||||
id: 'menuNotice',
|
||||
content: errorMessage,
|
||||
closable: true,
|
||||
});
|
||||
}
|
||||
return notifyInstance;
|
||||
},
|
||||
convertServerMenusToRoutes(serverMenus: any[]): RouteRecordNormalized[] {
|
||||
const convertMenu = (menu: any): any => {
|
||||
const menuPath = menu.path || menu.Path;
|
||||
const menuName = menu.name || menu.Name;
|
||||
const menuMeta = menu.meta || menu.Meta || {};
|
||||
const menuRedirect = menu.redirect || menu.Redirect;
|
||||
const menuComponent = menu.component || menu.Component;
|
||||
const menuChildren = menu.children || menu.Children || [];
|
||||
|
||||
const route: any = {
|
||||
path: menuPath,
|
||||
name: menuName,
|
||||
meta: {
|
||||
locale: menuMeta.locale || menuMeta.Locale || menuMeta.name || menuMeta.Name || '',
|
||||
requiresAuth: menuMeta.requiresAuth !== false && menuMeta.RequiresAuth !== false,
|
||||
icon: menuMeta.icon || menuMeta.Icon || '',
|
||||
order: menuMeta.order || menuMeta.Order || 0,
|
||||
hideInMenu: menuMeta.hideInMenu || menuMeta.HideInMenu || false,
|
||||
roles: menuMeta.roles || menuMeta.Roles || ['*'],
|
||||
},
|
||||
redirect: menuRedirect,
|
||||
};
|
||||
|
||||
if (menuComponent === 'DEFAULT_LAYOUT') {
|
||||
route.component = DEFAULT_LAYOUT;
|
||||
} else if (menuComponent) {
|
||||
let componentPath = menuComponent;
|
||||
if (!componentPath.includes('/')) {
|
||||
componentPath = `${componentPath}/index`;
|
||||
}
|
||||
route.component = serverMenuComponentMap[componentPath] || DEFAULT_LAYOUT;
|
||||
} else {
|
||||
route.component = DEFAULT_LAYOUT;
|
||||
}
|
||||
|
||||
if (Array.isArray(menuChildren) && menuChildren.length > 0) {
|
||||
route.children = menuChildren.map((child: any) => convertMenu(child));
|
||||
}
|
||||
|
||||
return route;
|
||||
};
|
||||
|
||||
return serverMenus.map(convertMenu);
|
||||
},
|
||||
clearServerMenu() {
|
||||
this.serverMenu = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default useAppStore;
|
||||
20
frontend/src/store/modules/app/types.ts
Normal file
20
frontend/src/store/modules/app/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
export interface AppState {
|
||||
theme: string;
|
||||
colorWeak: boolean;
|
||||
navbar: boolean;
|
||||
menu: boolean;
|
||||
topMenu: boolean;
|
||||
hideMenu: boolean;
|
||||
menuCollapse: boolean;
|
||||
footer: boolean;
|
||||
themeColor: string;
|
||||
menuWidth: number;
|
||||
globalSettings: boolean;
|
||||
device: string;
|
||||
tabBar: boolean;
|
||||
menuFromServer: boolean;
|
||||
serverMenu: RouteRecordNormalized[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
74
frontend/src/store/modules/tab-bar/index.ts
Normal file
74
frontend/src/store/modules/tab-bar/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
DEFAULT_ROUTE,
|
||||
DEFAULT_ROUTE_NAME,
|
||||
REDIRECT_ROUTE_NAME,
|
||||
} from '@/router/constants';
|
||||
import { isString } from '@/utils/is';
|
||||
import { TabBarState, TagProps } from './types';
|
||||
|
||||
const formatTag = (route: RouteLocationNormalized): TagProps => {
|
||||
const { name, meta, fullPath, query } = route;
|
||||
return {
|
||||
title: meta.locale || '',
|
||||
name: String(name),
|
||||
fullPath,
|
||||
query,
|
||||
ignoreCache: meta.ignoreCache,
|
||||
};
|
||||
};
|
||||
|
||||
const BAN_LIST = [REDIRECT_ROUTE_NAME];
|
||||
|
||||
const useAppStore = defineStore('tabBar', {
|
||||
state: (): TabBarState => ({
|
||||
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
|
||||
tagList: [DEFAULT_ROUTE],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getTabList(): TagProps[] {
|
||||
return this.tagList;
|
||||
},
|
||||
getCacheList(): string[] {
|
||||
return Array.from(this.cacheTabList);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateTabList(route: RouteLocationNormalized) {
|
||||
if (BAN_LIST.includes(route.name as string)) return;
|
||||
this.tagList.push(formatTag(route));
|
||||
if (!route.meta.ignoreCache) {
|
||||
this.cacheTabList.add(route.name as string);
|
||||
}
|
||||
},
|
||||
deleteTag(idx: number, tag: TagProps) {
|
||||
this.tagList.splice(idx, 1);
|
||||
this.cacheTabList.delete(tag.name);
|
||||
},
|
||||
addCache(name: string) {
|
||||
if (isString(name) && name !== '') this.cacheTabList.add(name);
|
||||
},
|
||||
deleteCache(tag: TagProps) {
|
||||
this.cacheTabList.delete(tag.name);
|
||||
},
|
||||
freshTabList(tags: TagProps[]) {
|
||||
this.tagList = tags;
|
||||
this.cacheTabList.clear();
|
||||
// 要先判断ignoreCache
|
||||
this.tagList
|
||||
.filter((el) => !el.ignoreCache)
|
||||
.map((el) => el.name)
|
||||
.forEach((x) => this.cacheTabList.add(x));
|
||||
},
|
||||
resetTabList() {
|
||||
this.tagList = [DEFAULT_ROUTE];
|
||||
this.cacheTabList.clear();
|
||||
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default useAppStore;
|
||||
12
frontend/src/store/modules/tab-bar/types.ts
Normal file
12
frontend/src/store/modules/tab-bar/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface TagProps {
|
||||
title: string;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
query?: any;
|
||||
ignoreCache?: boolean;
|
||||
}
|
||||
|
||||
export interface TabBarState {
|
||||
tagList: TagProps[];
|
||||
cacheTabList: Set<string>;
|
||||
}
|
||||
136
frontend/src/store/modules/user/index.ts
Normal file
136
frontend/src/store/modules/user/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
login as userLogin,
|
||||
logout as userLogout,
|
||||
getUserInfo,
|
||||
LoginData,
|
||||
} from '@/api/auth';
|
||||
import { setToken, clearToken } from '@/utils/auth';
|
||||
import { removeRouteListener } from '@/utils/route-listener';
|
||||
import { setTrackingUser } from '@/utils/monitor';
|
||||
import { resetRouteRegistration } from '@/router/guard/permission';
|
||||
import { RoleType, UserState, UserType } from './types';
|
||||
import useAppStore from '../app';
|
||||
|
||||
const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
avatar: undefined,
|
||||
job: undefined,
|
||||
organization: undefined,
|
||||
location: undefined,
|
||||
email: undefined,
|
||||
introduction: undefined,
|
||||
personalWebsite: undefined,
|
||||
jobName: undefined,
|
||||
organizationName: undefined,
|
||||
locationName: undefined,
|
||||
phone: undefined,
|
||||
registrationDate: undefined,
|
||||
accountId: undefined,
|
||||
certification: undefined,
|
||||
role: '',
|
||||
userType: '',
|
||||
passwordChanged: true,
|
||||
realName: undefined,
|
||||
loginName: undefined,
|
||||
roles: [],
|
||||
menuIds: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
userInfo(state: UserState): UserState {
|
||||
return { ...state };
|
||||
},
|
||||
isSystemAdmin(state: UserState): boolean {
|
||||
return state.userType === 'system_admin';
|
||||
},
|
||||
needsPasswordChange(state: UserState): boolean {
|
||||
return !state.passwordChanged;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
switchRoles() {
|
||||
return new Promise((resolve) => {
|
||||
this.role = this.role === 'user' ? 'admin' : 'user';
|
||||
resolve(this.role);
|
||||
});
|
||||
},
|
||||
setInfo(partial: Partial<UserState>) {
|
||||
this.$patch(partial);
|
||||
},
|
||||
|
||||
resetInfo() {
|
||||
this.$reset();
|
||||
},
|
||||
|
||||
async info() {
|
||||
const res = await getUserInfo();
|
||||
const userInfo = res.data;
|
||||
this.setInfo({
|
||||
id: userInfo.userId,
|
||||
name: userInfo.realName || userInfo.username,
|
||||
accountId: userInfo.userId,
|
||||
role: userInfo.role as RoleType,
|
||||
userType: userInfo.userType as UserType,
|
||||
passwordChanged: userInfo.passwordChanged ?? true,
|
||||
realName: userInfo.realName,
|
||||
loginName: userInfo.loginName || userInfo.username,
|
||||
roles: userInfo.roles || [],
|
||||
menuIds: userInfo.permissions || [],
|
||||
});
|
||||
setTrackingUser(userInfo.userId);
|
||||
},
|
||||
|
||||
async login(loginForm: LoginData) {
|
||||
try {
|
||||
const res = await userLogin(loginForm);
|
||||
const loginData = res.data;
|
||||
const token = loginData?.accessToken;
|
||||
if (!token) {
|
||||
throw new Error('登录响应中没有token');
|
||||
}
|
||||
setToken(token);
|
||||
|
||||
this.setInfo({
|
||||
id: loginData.userId,
|
||||
accountId: loginData.userId,
|
||||
name: loginData.realName || loginData.username,
|
||||
role: loginData.role as RoleType,
|
||||
userType: loginData.userType as UserType,
|
||||
passwordChanged: loginData.passwordChanged ?? true,
|
||||
realName: loginData.realName,
|
||||
loginName: loginData.loginName || loginData.username,
|
||||
roles: loginData.roles || [],
|
||||
menuIds: loginData.permissions || [],
|
||||
});
|
||||
setTrackingUser(loginData.userId);
|
||||
|
||||
return loginData;
|
||||
} catch (err) {
|
||||
clearToken();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
logoutCallBack() {
|
||||
const appStore = useAppStore();
|
||||
this.resetInfo();
|
||||
clearToken();
|
||||
setTrackingUser('');
|
||||
removeRouteListener();
|
||||
appStore.clearServerMenu();
|
||||
resetRouteRegistration();
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await userLogout();
|
||||
} finally {
|
||||
this.logoutCallBack();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default useUserStore;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user