Compare commits
116
Commits
9319427f67
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ae07175d | ||
|
|
eb9b7beff5 | ||
|
|
4ab63e77ed | ||
|
|
b6b528490c | ||
|
|
fb04d66e61 | ||
|
|
8fc52c574f | ||
|
|
4f1acaa451 | ||
|
|
d9a50e23dd | ||
|
|
244b4bd9fd | ||
|
|
ee49ef15b5 | ||
|
|
7b1c94b5ff | ||
|
|
8ba15d87b3 | ||
|
|
8acbe732ff | ||
|
|
e34543465a | ||
|
|
68617335f0 | ||
|
|
fc8c3ee3ad | ||
|
|
d16dbc69b4 | ||
|
|
36ff960fbd | ||
|
|
012f8edf8b | ||
|
|
5d7ed43570 | ||
|
|
843d7164f6 | ||
|
|
0c6efc4160 | ||
|
|
d4e89788c5 | ||
|
|
727319a6eb | ||
|
|
1eace4c082 | ||
|
|
2e2b837a20 | ||
|
|
9898962836 | ||
|
|
68cdaf38fa | ||
|
|
51fd90fc51 | ||
|
|
79d5ae6b66 | ||
|
|
892ba5886d | ||
|
|
1850ac640e | ||
|
|
5718a00e31 | ||
|
|
d48c012e68 | ||
|
|
699a117678 | ||
|
|
a758966fae | ||
|
|
4306cbd74b | ||
|
|
395c7f717e | ||
|
|
184d5b0403 | ||
|
|
48f3bd997c | ||
|
|
1e9666cefd | ||
|
|
c925b198d1 | ||
|
|
438145cae7 | ||
|
|
ffaadc26ae | ||
|
|
5b4dbb2b46 | ||
|
|
aa92ca40a2 | ||
|
|
e3db229f41 | ||
|
|
ca4220f92c | ||
|
|
0dc5c2f075 | ||
|
|
1685b22e1e | ||
|
|
c49e8d0d3f | ||
|
|
7c75edcc61 | ||
|
|
92403171d4 | ||
|
|
eafa5e1cf4 | ||
|
|
bc9e32d62a | ||
|
|
c5ed596008 | ||
|
|
fa281363fb | ||
|
|
1a4471e34d | ||
|
|
67e819c629 | ||
|
|
1224735aa8 | ||
|
|
f386899625 | ||
|
|
b48688cbab | ||
|
|
898dedd75a | ||
|
|
685dbc030d | ||
|
|
605c2a569f | ||
|
|
ac14452cae | ||
|
|
19687658e1 | ||
|
|
60b2658d4b | ||
|
|
71a14c46f9 | ||
|
|
a82b300b1a | ||
|
|
943c3708b0 | ||
|
|
9875832019 | ||
|
|
7e6a3e969f | ||
|
|
bb8a6936e4 | ||
|
|
4ab5effce6 | ||
|
|
a26c98b92b | ||
|
|
6f106e43af | ||
|
|
a8c09c1db5 | ||
|
|
2f6cdbb3cd | ||
|
|
aea53e0766 | ||
|
|
4455b95e37 | ||
|
|
2b1874943f | ||
|
|
184c604bee | ||
|
|
840673bda5 | ||
|
|
9c07af2b59 | ||
|
|
b9cdabe045 | ||
|
|
1304e71b7a | ||
|
|
9f60450ffa | ||
|
|
26eaf1eddb | ||
|
|
e340d9a7d1 | ||
|
|
8a0334a020 | ||
|
|
63bf972c17 | ||
|
|
a89bdd839b | ||
|
|
c146dc2f2f | ||
|
|
2f238875e1 | ||
|
|
fef0233cc4 | ||
|
|
de7225b61e | ||
|
|
d2643a3697 | ||
|
|
04fc84dde6 | ||
|
|
34b2449462 | ||
|
|
672c22d9b6 | ||
|
|
beaeb4a234 | ||
|
|
7f3de60423 | ||
|
|
0f74abc5d4 | ||
|
|
3d53f03246 | ||
|
|
20be7f4817 | ||
|
|
d8936d9730 | ||
|
|
44a03186da | ||
|
|
f644c278c9 | ||
|
|
db519d9af2 | ||
|
|
79217187ac | ||
|
|
2dfc6d6aaf | ||
|
|
d3bc1833f8 | ||
|
|
9588ddbf31 | ||
|
|
99df6c08ac | ||
|
|
576fea3770 |
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
enabled: true
|
||||
updatedAt: 2026-09-11T14:51:38.120Z
|
||||
provider:
|
||||
---
|
||||
|
||||
调用前先执行codegraph的mcp
|
||||
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
+19
-1
@@ -52,9 +52,23 @@ backend/dist
|
||||
backend/dist.zip
|
||||
backend/output
|
||||
|
||||
# website
|
||||
website/dist
|
||||
website/dist.zip
|
||||
|
||||
# go
|
||||
go/go-platform.zip
|
||||
go/server.exe
|
||||
# Go 构建产物(go build 默认输出 main.exe / server.exe 等),不入库
|
||||
go/*.exe
|
||||
go/conf
|
||||
# go test ./routers/ 会在包目录下创建默认验证文件目录(空目录,不入库)
|
||||
go/routers/verify/
|
||||
# 增量部署工具(update-server.bat)编译产物与运行时文件
|
||||
go/bin/
|
||||
go/.deploy_known_hosts
|
||||
# SSH 私钥(部署用):绝对不能入库
|
||||
go/*_id_ed25519
|
||||
go/*.pem
|
||||
|
||||
# codegraph
|
||||
.codegraph/*
|
||||
@@ -64,3 +78,7 @@ go/server.exe
|
||||
.mimocode/package.json
|
||||
.mimocode/package-lock.json
|
||||
.mimocode/bun.lock
|
||||
|
||||
docs/sql/
|
||||
|
||||
go/update-server-outside.bat
|
||||
@@ -1 +1 @@
|
||||
{"pid":26648,"startedAt":1787847660038}
|
||||
{"pid":21228,"startedAt":1788141590195}
|
||||
Whitespace-only changes.
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,51 @@
|
||||
# 修复登录无限循环(登录成功后弹回登录页)
|
||||
|
||||
## Context(问题与证据链)
|
||||
|
||||
用户反馈:登录成功后又回到登录界面,无限循环。排查已确认(含用户亲证):
|
||||
|
||||
**用户确认的现象**
|
||||
- 登录页地址栏为 `api.yunzer.cn`(远程线上认证中心,非本地 9000)
|
||||
- 登录后见过「选择要进入的企业」界面(hero920103 绑定 2 家企业),**选完企业后又弹回登录页**
|
||||
|
||||
**数据库证据(go-platform 库)**
|
||||
- hero920103 = identity_id 2(李志强),绑定两家企业(tid=1、tid=2)→ 登录必走"待选企业"分支
|
||||
- `yz_auth_session`:18:09~18:12 连续 4 条会话被踢(revoked=1, revoke_reason='kicked');最新会话 id=386(18:18:29,tid=2,client_id 为空)
|
||||
- 18:24:42 登录日志(id=218,user_id=1)与 hero920103 实际 identity_id=2 不符 → **该日志由远程旧版代码写入**(字段语义不同),且 IP=127.0.0.1/::1 符合"远程 nginx 反代回环"特征
|
||||
|
||||
**代码机制(go/services/auth/session.go L61-134)**
|
||||
- `CreateSession` 按 identity_id **全局**统计活跃会话,超 MaxSession 即踢最旧会话(kicked),不区分 tid/client/服务实例
|
||||
- 登录 → 远程建会话 → 企业选择 → 新会话又创建 → 互踢 → authorize 发现会话已吊销 → 送回登录页 → 再登录 → **无限循环**
|
||||
- 远程为旧版代码(字段错位可证),其企业选择后的会话处理存在缺陷,无法本地修复
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 第一步:本地开发认证切换到本地 9000 实例(核心修复)
|
||||
|
||||
修改 `backend/.env.development`:
|
||||
|
||||
```
|
||||
VITE_AUTH_BASE='http://127.0.0.1:9000/auth'
|
||||
```
|
||||
|
||||
本地 9000 已注册全套认证端点(/auth/login、/auth/authorize、/auth/token、/auth/userinfo、/auth/switch-tenant,见 `go/routers/auth/auth.go`),且为最新代码(企业选择、cookie、PKCE 逻辑完整)。
|
||||
|
||||
### 第二步:配套校验与清理
|
||||
|
||||
1. 核对 `yz_auth_client` 表 yz-backend 的 redirect_uris 白名单包含 `http://127.0.0.1:4401/auth-callback.html`(不匹配则用 MySQL MCP 补登)
|
||||
2. 清理脏数据:将 hero920103(identity_id=2)名下 revoked=0 的旧会话标记 revoked=1(reason='cleanup'),避免旧会话占用互踢名额
|
||||
3. 重启 Vite dev server 使 env 生效
|
||||
4. 验证本地登录页 HTML 表单嵌入 client_id(curl 抓取确认,防止空 client 会话再现)
|
||||
|
||||
### 第三步:端到端验证
|
||||
|
||||
1. 无痕窗口(避免残留 localStorage token/userInfo)访问 `http://127.0.0.1:4401`
|
||||
2. 完整走:跳本地登录页 → 输入 hero920103 → 企业选择(展示 2 家)→ 选 tid=2 → 回跳 auth-callback → 进入工作台,无循环
|
||||
3. MySQL MCP 复查 `yz_auth_session` 无异常互踢
|
||||
4. 登录恢复后,验证原任务功能:apps/oa/schedule 日历按 hero920103 排班显示上班(绿)/休息(橙)背景(后端 `/backend/oa/scheduling/my` 已就绪)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `backend/.env.development`(唯一代码修改:VITE_AUTH_BASE 指向本地)
|
||||
- 数据库:`yz_auth_client`(白名单核对)、`yz_auth_session`(脏数据清理)
|
||||
- 不改 Go 代码(互踢规则是合理安全设计;根因是开发环境认证指向了旧版远程中心)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 小程序端 OA 功能实现计划
|
||||
|
||||
## Context
|
||||
|
||||
Web 管理端(`backend/`)已具备完整的 OA 四大功能(通知公告、文档库、薪酬工资单、员工档案),
|
||||
但移动端(`uniapp/`)目前只有统一 webview 兜底,OA 子系统里除「日程提醒/记事本」外均无原生页面。
|
||||
本次目标:参照 `backend/` 对应功能,在小程序端提供 OA 四大功能的原生体验。
|
||||
|
||||
**已确认的范围与约束**
|
||||
- 四大功能:通知公告(只读浏览)、文档库(浏览+下载+收藏)、薪酬工资单(**仅本人**)、员工档案(**仅本人**、只读)。
|
||||
- 后端管理端 OA 接口均为 `/backend/oa/*` 且强制 `user_type=backend`,移动端令牌调不到 →
|
||||
必须新增面向客户端的 `/app/oa/*` 只读接口。
|
||||
- 写操作(发布公告、上传文档、编辑工资单、维护档案)继续留在 Web 端。
|
||||
- 仅本人数据通过「登录账号 → 员工」映射解析(与 `backend_auth.go` 一致)。
|
||||
- 微信小程序为主端,兼容 H5/APK;沿用既有 `Sub*`/工具页的卡片风格与 `FaIcon` 图标。
|
||||
|
||||
## 现状关键参考
|
||||
|
||||
| 用途 | 位置 |
|
||||
| --- | --- |
|
||||
| 移动端控制器鉴权/响应范式 | [app_reminder.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/app_reminder.go) |
|
||||
| 移动端路由注册 | [app.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/routers/app/app.go) |
|
||||
| 公告控制器/模型 | [backend_oa_notice.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_notice.go) / [oa_notice.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/oa_notice.go) |
|
||||
| 文档控制器/服务/模型 | [backend_oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_document.go) / [oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/services/oa_document.go) / [oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/oa_document.go) |
|
||||
| 薪酬控制器/模型 | [backend_oa_compensation.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_compensation.go) / [backend_oa_compensation.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/backend_oa_compensation.go) |
|
||||
| 员工档案控制器/模型 | [backend_employee_file.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_employee_file.go) / [backend_employee_file.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/backend_employee_file.go) |
|
||||
| 账号→员工映射范式 | [backend_auth.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_auth.go#L266-L289) |
|
||||
| 前端请求封装 | [request.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/request.js) |
|
||||
| 前端 API 封装范式 | [schedule.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/schedule.js) |
|
||||
| 列表/详情页样式范式 | [schedule/index.vue](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/pages/tools/schedule/index.vue) |
|
||||
| 菜单跳转分发 | [nav.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/utils/nav.js) |
|
||||
| OA 概览页组件 | [SubHome.vue](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/components/sub/SubHome.vue) |
|
||||
|
||||
## 一、后端:新增客户端 OA 只读接口
|
||||
|
||||
### 1. 新增 `go/controllers/app_oa.go`
|
||||
|
||||
`AppOaController` + 复用式辅助方法:
|
||||
- `oaClaims()`:允许 `backend/app/platform`(与 `AppReminderController.appClaims` 同口径)。
|
||||
- `oaOk/oaErr()`:统一 `{code,msg,data}` 响应。
|
||||
- `oaTenantUID(claims)`:`services.ResolveTenantUID(tid, claims.UserID)`。
|
||||
- `oaMyEmployee(tid, userID) (*models.BackendEmployee, bool)`:查 `SystemTenantUser`(uid+tid)取
|
||||
`account`,再按 `tid + account` 查 `BackendEmployee`(对齐 `backend_auth.go` L266-289)。
|
||||
|
||||
### 2. 接口清单
|
||||
|
||||
| 方法 | 路由 | 说明 | 复用 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/app/oa/notice/list` | 仅 `status=1`、`is_deleted=0`、`tid`;`is_top` 优先 + `publish_time` 倒序;`keyword/page/pageSize` | `models.OaNotice` |
|
||||
| GET | `/app/oa/notice/:id` | 详情,`read_count+1`(沿用 Raw UPDATE 写法) | 同上 |
|
||||
| GET | `/app/oa/document/categories` | `scope=shared/personal/all` 分类树 | `services.OaDocCategoryTree` |
|
||||
| GET | `/app/oa/document/list` | 文档分页(scope/category_id/uncategorized/keyword/mine/star/doc_type) | `services.OaDocList` |
|
||||
| GET | `/app/oa/document/:id` | 文档详情(累加查看数) | `services.OaDocView` |
|
||||
| GET | `/app/oa/document/download/:id` | 记录下载数并返回下载地址 | `services.OaDocDownload` |
|
||||
| POST | `/app/oa/document/:id/star` | 收藏/取消收藏 | `services.OaDocToggleStar` |
|
||||
| GET | `/app/oa/compensation/payrolls` | **仅本人**薪资单,`employee_id = 我的员工ID` + `is_deleted=0`;`payroll_month` 可选 | `payrollMap()`(包级函数,可直接复用) |
|
||||
| GET | `/app/oa/compensation/payrolls/:id` | 明细 + 自定义增减项,二次校验 `employee_id == 我的员工ID` | `models.BackendOaPayrollItem` |
|
||||
| GET | `/app/oa/employee-file` | **仅本人**档案 + 子记录(教育/工作/合同/证照) | `(&BackendEmployeeFileController{}).efAssembleDTOList`、`efFileCompleteness`、`efOrgNameMap`(均只依赖 `models.Orm`,零值控制器可安全调用) |
|
||||
|
||||
### 3. 路由注册(`go/routers/app/app.go`)
|
||||
|
||||
追加 `// OA 协同办公` 段。**注意 beego 按注册顺序匹配**:字面量路由必须排在 `:id` 通配之前
|
||||
(如 `document/categories`、`document/list`、`document/download/:id` 先于 `document/:id`)。
|
||||
|
||||
## 二、前端:API 封装与页面
|
||||
|
||||
### 1. 新增 `uniapp/api/oa.js`
|
||||
|
||||
依据 [schedule.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/schedule.js) 范式封装:
|
||||
`fetchNotices/getNotice`、`fetchDocCategories/fetchDocs/getDoc/downloadDoc/toggleDocStar`、
|
||||
`fetchPayrolls/getPayroll`、`getMyEmployeeFile`,并导出展示用枚举标签
|
||||
(公告类型/级别、文档类型、薪资单状态、在职状态、婚姻状况)。
|
||||
|
||||
### 2. 新增页面(统一沿用卡片 + `FaIcon` + `page-common.scss`)
|
||||
|
||||
| 页面 | 文件 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 公告列表 | `pages/oa/notice/index.vue` | 搜索、置顶/重要标记、发布人+时间+阅读数,点击进详情 |
|
||||
| 公告详情 | `pages/oa/notice/detail.vue` | 标题、类型/级别标签、发布信息、正文 |
|
||||
| 文档库 | `pages/oa/document/index.vue` | 共享/私密空间切换、分类横向筛选、搜索、文档列表(类型图标+大小+分类) |
|
||||
| 文档详情 | `pages/oa/document/detail.vue` | 元信息、摘要、下载(`uni.downloadFile`+`uni.openDocument`,失败降级复制链接)、收藏切换 |
|
||||
| 我的工资单 | `pages/oa/compensation/index.vue` | 月份筛选、实发金额高亮、发放状态,点击进明细 |
|
||||
| 工资单明细 | `pages/oa/compensation/detail.vue` | 应发/扣款分项、社保公积金个税、自定义增减项、实发合计 |
|
||||
| 我的档案 | `pages/oa/employeefile/index.vue` | 基本信息、在岗状态、完整度、教育/工作/合同经历列表(只读) |
|
||||
|
||||
### 3. 注册路由(`uniapp/pages.json`)
|
||||
|
||||
`pages` 数组追加上述 7 个页面,`navigationBarTitleText` 分别为
|
||||
「通知公告 / 公告详情 / 文档库 / 文档详情 / 我的工资单 / 工资单明细 / 我的档案」,
|
||||
背景统一 `#F8FAFC`。
|
||||
|
||||
### 4. 菜单跳转映射(`uniapp/utils/nav.js`)
|
||||
|
||||
在 `NATIVE_RULES` 中补充 OA 原生路由(顺序放在既有规则之后,模式从具体到宽泛):
|
||||
- 通知公告:「通知公告|公告|notice」
|
||||
- 文档库:「文档库|文档管理|文档图谱|document」
|
||||
- 薪酬工资单:「薪酬|工资|薪资|compensation|payroll」
|
||||
- 员工档案:「员工档案|人事档案|档案|employeefile|employeeFile」
|
||||
|
||||
### 5. OA 概览页新增「最新公告」卡片(`SubHome.vue`)
|
||||
|
||||
在「日程待办」区块后新增一个 `v-if="sub.module === 'oa'"` 的区块:
|
||||
调用 `/app/oa/notice/list`(pageSize=3)展示标题 + 发布时间,「全部」跳转公告列表页。
|
||||
仅对 OA 生效,不影响 crm/erp/cms/tools 概览。
|
||||
|
||||
## 三、验证
|
||||
|
||||
1. **后端编译**:`cd go && go build ./...` 通过。
|
||||
2. **前端编译**:`cd uniapp && npm run dev:mp-weixin`,确认产物 `dist/dev/mp-weixin` 无报错。
|
||||
3. **接口自测**(若可连库):以 app 令牌请求 `/app/oa/notice/list`、`/app/oa/document/list?scope=shared`、
|
||||
`/app/oa/compensation/payrolls`、`/app/oa/employee-file`,确认鉴权、租户隔离与本人限制生效。
|
||||
4. **端上验证**:微信开发者工具打开 OA 子系统,从菜单进入四个功能页,检查列表/详情/下载/收藏与公告卡片。
|
||||
(注:数据库当前连接超时,接口数据验证受限于环境,必要时由用户提供实际层级/菜单信息。)
|
||||
Vendored
+18
@@ -11,13 +11,17 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AsideMenuItem: typeof import('./src/components/AsideMenuItem.vue')['default']
|
||||
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
|
||||
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
@@ -25,8 +29,11 @@ declare module 'vue' {
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
@@ -50,15 +57,21 @@ declare module 'vue' {
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElResult: typeof import('element-plus/es')['ElResult']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
@@ -68,13 +81,18 @@ declare module 'vue' {
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/MenuIcon.vue')['default']
|
||||
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TenantSwitcher: typeof import('./src/components/TenantSwitcher.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
Generated
+869
-12
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@turbodocx/html-to-docx": "^1.22.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@umoteam/editor": "^10.2.1",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"axios": "^1.13.1",
|
||||
"chart": "^0.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -24,6 +29,8 @@
|
||||
"os": "^0.1.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pinia": "^3.0.3",
|
||||
"pinyin-pro": "^3.29.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"v-viewer": "^3.0.11",
|
||||
"vue": "^3.5.22",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>正在完成登录…</title>
|
||||
<link rel="preload" as="image" href="/loading.gif" />
|
||||
<style>
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.loading-gif {
|
||||
display: block;
|
||||
width: 160px;
|
||||
max-width: 45vw;
|
||||
height: auto;
|
||||
}
|
||||
.loading-text {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: #8a8f9c;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 登录回跳中转:仅做 code 转发,画面保持白底 + 居中 loading -->
|
||||
<div class="loading-wrap">
|
||||
<img class="loading-gif" src="/loading.gif" alt="登录中,请稍后…" />
|
||||
<p class="loading-text">登录中,请稍后…</p>
|
||||
</div>
|
||||
<script>
|
||||
/**
|
||||
* 统一认证中心回调中转页。
|
||||
*
|
||||
* 为什么不直接用 hash 路由 /#/auth/callback 作为 redirect_uri:
|
||||
* URL 中的 "#" 会被浏览器当作片段标识符,其后的内容不会作为查询参数发送到服务端,
|
||||
* 导致认证中心收到的 redirect_uri 被截断(只剩根路径),换不到授权码。
|
||||
*
|
||||
* 因此用一个不带 # 的静态页接收 code,再转发给 SPA 的 hash 路由处理。
|
||||
*/
|
||||
(function () {
|
||||
var query = window.location.search || '';
|
||||
var target = window.location.origin + '/#/auth/callback' + query;
|
||||
window.location.replace(target);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*
|
||||
* Copyright 2024 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @licend The above is the entire license notice for the
|
||||
* JavaScript code in this page
|
||||
*/
|
||||
/**
|
||||
* pdfjsVersion = 6.2.108
|
||||
* pdfjsBuild = 0365cbde0
|
||||
*/
|
||||
const e=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),t=[1/0,1/0,-1/0,-1/0],n=new Float32Array(t),a=[.001,0,0,.001,0,0],s=.35/1.35,r="http://www.w3.org/2000/svg",i=1,o=2,l=4,f=8,c=16,h=64,u=128,m=256,p="pdfjs_internal_editor_",d=3,g=9,b=13,w=15,j=101,k={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},y=1,q=2,v=3,S=0,x=4,C=1,F=2,T=3,R={TEXT:1,LINK:2,FREETEXT:3,LINE:4,SQUARE:5,CIRCLE:6,POLYGON:7,POLYLINE:8,HIGHLIGHT:9,UNDERLINE:10,SQUIGGLY:11,STRIKEOUT:12,STAMP:13,CARET:14,INK:15,POPUP:16,FILEATTACHMENT:17,SOUND:18,MOVIE:19,WIDGET:20,SCREEN:21,PRINTERMARK:22,TRAPNET:23,WATERMARK:24,THREED:25,REDACT:26,RICHMEDIA:27},O="Group",H="R",D=0,M=4,N=1,E=2,_=4,z=16,L=32,U=128,W=512,X=1,K=2,G=4096,V=8192,$=32768,Y=65536,J=131072,Q=1048576,Z=2097152,ee=8388608,te=16777216,ne=1,ae=2,se=3,re=4,ie=5,oe={E:"Mouse Enter",X:"Mouse Exit",D:"Mouse Down",U:"Mouse Up",Fo:"Focus",Bl:"Blur",PO:"PageOpen",PC:"PageClose",PV:"PageVisible",PI:"PageInvisible",K:"Keystroke",F:"Format",V:"Validate",C:"Calculate"},le={WC:"WillClose",WS:"WillSave",DS:"DidSave",WP:"WillPrint",DP:"DidPrint"},fe={O:"PageOpen",C:"PageClose"},ce=1,he=5,ue=1,me=2,pe=3,de=4,ge=5,be=6,we=7,je=8,ke=9,ye=10,qe=11,ve=12,Se=13,Ae=14,xe=15,Ce=16,Ie=17,Fe=18,Te=19,Re=20,Oe=21,He=22,Be=23,De=24,Me=25,Ne=26,Pe=27,Ee=28,_e=29,ze=30,Le=31,Ue=32,We=33,Xe=34,Ke=35,Ge=36,Ve=37,$e=38,Ye=39,Je=40,Qe=41,Ze=42,et=43,tt=44,nt=45,at=46,st=47,rt=48,it=49,ot=50,lt=51,ft=52,ct=53,ht=54,ut=55,mt=56,pt=57,dt=58,gt=59,bt=60,wt=61,jt=62,kt=63,yt=64,qt=65,vt=66,St=67,At=68,xt=69,Ct=70,It=71,Ft=72,Tt=73,Rt=74,Ot=75,Ht=76,Bt=77,Dt=80,Mt=81,Nt=83,Pt=84,Et=85,_t=86,zt=87,Lt=88,Ut=89,Wt=90,Xt=91,Kt=92,Gt=93,Vt=94,$t=0,Yt=1,Jt=2,Qt=3,Zt=4,en=1,tn=2;let nn=ce;function getVerbosityLevel(){return nn}function info(e){nn>=he&&console.info(`Info: ${e}`)}function warn(e){nn>=ce&&console.warn(`Warning: ${e}`)}function unreachable(e){throw new Error(e)}function assert(e,t){e||unreachable(t)}function createValidAbsoluteUrl(e,t=null,n=null){if(!e)return null;if(n&&"string"==typeof e){if(n.addDefaultProtocol&&e.startsWith("www.")){const t=e.match(/\./g);t?.length>=2&&(e=`http://${e}`)}if(n.tryConvertEncoding)try{e=stringToUTF8String(e)}catch{}}const a=t?URL.parse(e,t):URL.parse(e);return function _isValidProtocol(e){switch(e?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(a)?a:null}function shadow(e,t,n,a=!1){Object.defineProperty(e,t,{value:n,enumerable:!a,configurable:!0,writable:!1});return n}const an=function BaseExceptionClosure(){function BaseException(e,t){this.message=e;this.name=t}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends an{constructor(e,t){super(e,"PasswordException");this.code=t}}class UnknownErrorException extends an{constructor(e,t){super(e,"UnknownErrorException");this.details=t}}class InvalidPDFException extends an{constructor(e){super(e,"InvalidPDFException")}}class ResponseException extends an{constructor(e,t,n){super(e,"ResponseException");this.status=t;this.missing=n}}class FormatError extends an{constructor(e){super(e,"FormatError")}}class AbortException extends an{constructor(e){super(e,"AbortException")}}function bytesToString(e){"object"==typeof e&&void 0!==e?.length||unreachable("Invalid argument for bytesToString");const t=e.length,n=8192;if(t<n)return String.fromCharCode.apply(null,e);const a=[];for(let s=0;s<t;s+=n){const r=Math.min(s+n,t),i=e.subarray(s,r);a.push(String.fromCharCode.apply(null,i))}return a.join("")}function stringToBytes(e){"string"!=typeof e&&unreachable("Invalid argument for stringToBytes");const t=e.length,n=new Uint8Array(t);for(let a=0;a<t;++a)n[a]=255&e.charCodeAt(a);return n}class FeatureTest{static get isLittleEndian(){const e=new Uint8Array(4);e[0]=1;return shadow(this,"isLittleEndian",1===new Uint32Array(e.buffer,0,1)[0])}static get isOffscreenCanvasSupported(){return shadow(this,"isOffscreenCanvasSupported","undefined"!=typeof OffscreenCanvas)}static get isImageDecoderSupported(){return shadow(this,"isImageDecoderSupported","undefined"!=typeof ImageDecoder)}static get isFloat16ArraySupported(){return shadow(this,"isFloat16ArraySupported","undefined"!=typeof Float16Array)}static get isSanitizerSupported(){return shadow(this,"isSanitizerSupported","undefined"!=typeof Sanitizer)}static get platform(){const{platform:e,userAgent:t}=navigator;return shadow(this,"platform",{isAndroid:t.includes("Android"),isLinux:e.includes("Linux"),isMac:e.includes("Mac"),isWindows:e.includes("Win"),isFirefox:t.includes("Firefox")})}static get isCanvasFilterSupported(){let e;this.isOffscreenCanvasSupported&&(e=new OffscreenCanvas(1,1).getContext("2d"));return shadow(this,"isCanvasFilterSupported",void 0!==e?.filter)}static get isAlphaColorLine truncated
|
||||
/*webpackIgnore: true*/
|
||||
/*@vite-ignore*/
|
||||
`${WasmImage.#T}${this._noWasmFilename}`)).default()}catch(e){warn(`#getJsModule: ${e}`)}e(t)}async#J(e,t,n){try{this.#V||(WasmImage.#G?this.#V=await fetchBinaryData(`${WasmImage.#T}${this._filename}`):this.#V=await WasmImage.#X.sendWithPromise("FetchBinaryData",{kind:"wasmUrl",filename:this._filename}));return n((await WebAssembly.instantiate(this.#V,t)).instance)}catch(t){warn(`#instantiateWasm: ${t}`);this.#Y(e);return null}}_getModule(e){if(!this.#$){const{promise:t,resolve:n}=Promise.withResolvers(),a=[t];WasmImage.#F?a.push(e({warn,instantiateWasm:this.#J.bind(this,n)})):this.#Y(n);this.#$=Promise.race(a)}return this.#$}async decode(e,t){unreachable("Abstract method `decode` called")}}class Jbig2Error extends an{constructor(e){super(e,"Jbig2Error")}}class JBig2CCITTFaxImage extends WasmImage{_filename="jbig2.wasm";_noWasmFilename="jbig2_nowasm_fallback.js";static get instance(){return shadow(this,"instance",new JBig2CCITTFaxImage(!0))}async decode(e,t,n,a,s){const r=await this._getModule(ea);if(!r)throw new Jbig2Error("JBig2 failed to initialize");let i,o;try{const l=e.length;i=r._malloc(l);r.writeArrayToMemory(e,i);if(s)r._ccitt_decode(i,l,t,n,s.K,s.EndOfLine?1:0,s.EncodedByteAlign?1:0,s.BlackIs1?1:0,s.Columns,s.Rows);else{const e=a?a.length:0;if(e>0){o=r._malloc(e);r.writeArrayToMemory(a,o)}r._jbig2_decode(i,l,t,n,o,e)}if(!r.imageData)throw new Jbig2Error("Unknown error");const{imageData:f}=r;r.imageData=null;return f}finally{i&&r._free(i);o&&r._free(o)}}}class CCITTFaxStream extends DecodeStream{constructor(e,t,n){super(t);this.stream=e;this.maybeLength=t;this.dict=e.dict;n instanceof Dict||(n=Dict.empty);this.params={K:n.get("K")||0,EndOfLine:!!n.get("EndOfLine"),EncodedByteAlign:!!n.get("EncodedByteAlign"),Columns:n.get("Columns")||1728,Rows:n.get("Rows")||0,EndOfBlock:!!(n.get("EndOfBlock")??1),BlackIs1:!!n.get("BlackIs1")}}get bytes(){return shadow(this,"bytes",this.stream.getBytes(this.maybeLength))}get isImageStream(){return!0}get isAsyncDecoder(){return!0}async decodeImage(e,t,n){if(this.eof)return this.buffer;e??=this.stream.isAsync&&await this.stream.asyncGetBytes()||this.bytes;this.buffer=await JBig2CCITTFaxImage.instance.decode(e,this.dict.get("W","Width"),this.dict.get("H","Height"),null,this.params);this.bufferLength=this.buffer.length;this.eof=!0;return this.buffer}}const ta=new Int32Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),na=new Int32Array([3,4,5,6,7,8,9,10,65547,65549,65551,65553,131091,131095,131099,131103,196643,196651,196659,196667,262211,262227,262243,262259,327811,327843,327875,327907,258,258,258]),aa=new Int32Array([1,2,3,4,65541,65543,131081,131085,196625,196633,262177,262193,327745,327777,393345,393409,459009,459137,524801,525057,590849,591361,657409,658433,724993,727041,794625,798721,868353,876545]),sa=[new Int32Array([459008,524368,524304,524568,459024,524400,524336,590016,459016,524384,524320,589984,524288,524416,524352,590048,459012,524376,524312,589968,459028,524408,524344,590032,459020,524392,524328,59e4,524296,524424,524360,590064,459010,524372,524308,524572,459026,524404,524340,590024,459018,524388,524324,589992,524292,524420,524356,590056,459014,524380,524316,589976,459030,524412,524348,590040,459022,524396,524332,590008,524300,524428,524364,590072,459009,524370,524306,524570,459025,524402,524338,590020,459017,524386,524322,589988,524290,524418,524354,590052,459013,524378,524314,589972,459029,524410,524346,590036,459021,524394,524330,590004,524298,524426,524362,590068,459011,524374,524310,524574,459027,524406,524342,590028,459019,524390,524326,589996,524294,524422,524358,590060,459015,524382,524318,589980,459031,524414,524350,590044,459023,524398,524334,590012,524302,524430,524366,590076,459008,524369,524305,524569,459024,524401,524337,590018,459016,524385,524321,589986,524289,524417,524353,590050,459012,524377,524313,589970,459028,524409,524345,590034,459020,524393,524329,590002,524297,524425,524361,590066,459010,524373,524309,524573,459026,524405,524341,590026,459018,524389,524325,589994,524293,524421,524357,590058,459014,524381,524317,589978,459030,524413,524349,590042,459022,524397,524333,590010,524301,524429,524365,590074,459009,524371,524307,524571,459025,524403,524339,590022,459017,524387,524323,589990,524291,524419,524355,590054,459013,524379,524315,589974,459029,524411,524347,590038,459021,524395,524331,590006,524299,524427,524363,590070,459011,524375,524311,524575,459027,524407,524343,590030,459019,524391,524327,589998,524295,524423,524359,590062,459015,524383,524319,589982,459031,524415,524351,590046,459023,524399,524335,590014,524303,524431,524367,590078,459008,524368,524304,524568,459024,524400,524336,590017,459016,524384,524320,589985,524288,524416,524352,590049,459012,524376,524312,589969,459028,524408,524344,590033,459020,524392,524328,590001,524296,524424,524360,590065,459010,524372,524308,524572,459026,524404,524340,590025,459018,524388,524324,589993,524292,524420,524356,590057,459014,524380,524316,589977,459030,524412,524348,59004Line truncated
|
||||
+18
-1
@@ -1,8 +1,25 @@
|
||||
<script setup>
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { onMounted } from 'vue'
|
||||
import { getBackendBrand } from '@/api/site'
|
||||
import { applyFavicon } from '@/utils/favicon'
|
||||
|
||||
// 浏览器标签页图标:取平台「平台设置 - 平台信息」的站点图标,未配置则保留默认图标
|
||||
onMounted(async () => {
|
||||
if (!localStorage.getItem('token')) return
|
||||
try {
|
||||
const res = await getBackendBrand()
|
||||
if (res?.code === 200) applyFavicon(res.data?.ico)
|
||||
} catch (error) {
|
||||
// 图标属于装饰信息,取不到时不影响页面
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getAccountSetList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/account-set/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountSetDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/account-set/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createAccountSet(data) {
|
||||
return request({
|
||||
url: '/backend/erp/account-set',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAccountSet(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/account-set/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAccountSet(id) {
|
||||
return request({
|
||||
url: `/backend/erp/account-set/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleAccountSetStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/account-set/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// AI接入配置:scope='tenant' 只取租户级配置(OA 设置里的 AI 设置使用),
|
||||
// 不传则返回「租户级 + 个人」配置(AI 广场选择接入时使用)
|
||||
export function getAiProviderList(params) {
|
||||
return request({
|
||||
url: '/backend/ai/provider/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createAiProvider(data) {
|
||||
return request({
|
||||
url: '/backend/ai/provider',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiProvider(id, data) {
|
||||
return request({
|
||||
url: `/backend/ai/provider/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAiProvider(id) {
|
||||
return request({
|
||||
url: `/backend/ai/provider/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function testAiProvider(data) {
|
||||
return request({
|
||||
url: '/backend/ai/provider/test',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 聊天会话
|
||||
export function getChatSessionList() {
|
||||
return request({
|
||||
url: '/backend/ai/chat/session/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createChatSession(data) {
|
||||
return request({
|
||||
url: '/backend/ai/chat/session',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateChatSession(id, data) {
|
||||
return request({
|
||||
url: `/backend/ai/chat/session/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteChatSession(id) {
|
||||
return request({
|
||||
url: `/backend/ai/chat/session/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 聊天消息
|
||||
export function getChatMessageList(sessionId) {
|
||||
return request({
|
||||
url: '/backend/ai/chat/message/list',
|
||||
method: 'get',
|
||||
params: { session_id: sessionId }
|
||||
})
|
||||
}
|
||||
|
||||
export function sendChatMessage(data) {
|
||||
return request({
|
||||
url: '/backend/ai/chat/send',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteChatMessage(id) {
|
||||
return request({
|
||||
url: `/backend/ai/chat/message/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 角色预设
|
||||
export function getAiPresetList() {
|
||||
return request({
|
||||
url: '/backend/ai/preset/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createAiPreset(data) {
|
||||
return request({
|
||||
url: '/backend/ai/preset',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiPreset(id, data) {
|
||||
return request({
|
||||
url: `/backend/ai/preset/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAiPreset(id) {
|
||||
return request({
|
||||
url: `/backend/ai/preset/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function setAiPresetDefault(id) {
|
||||
return request({
|
||||
url: `/backend/ai/preset/${id}/default`,
|
||||
method: 'put'
|
||||
})
|
||||
}
|
||||
|
||||
// 智能生成公司信息
|
||||
export function smartGenerateCompany(data) {
|
||||
return request({
|
||||
url: '/backend/ai/smart-generate/company',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// MCP工具管理
|
||||
export function getMcpToolList() {
|
||||
return request({
|
||||
url: '/backend/mcp/tool/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createMcpTool(data) {
|
||||
return request({
|
||||
url: '/backend/mcp/tool',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMcpTool(id, data) {
|
||||
return request({
|
||||
url: `/backend/mcp/tool/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMcpTool(id) {
|
||||
return request({
|
||||
url: `/backend/mcp/tool/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function testMcpTool(data) {
|
||||
return request({
|
||||
url: '/backend/mcp/tool/test',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// MCP服务器配置管理
|
||||
export function getMcpServerList() {
|
||||
return request({
|
||||
url: '/backend/mcp/server/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createMcpServer(data) {
|
||||
return request({
|
||||
url: '/backend/mcp/server',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMcpServer(id, data) {
|
||||
return request({
|
||||
url: `/backend/mcp/server/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMcpServer(id) {
|
||||
return request({
|
||||
url: `/backend/mcp/server/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleMcpServer(id, enabled) {
|
||||
return request({
|
||||
url: `/backend/mcp/server/${id}/toggle`,
|
||||
method: 'put',
|
||||
data: { enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function testMcpServer(data) {
|
||||
return request({
|
||||
url: '/backend/mcp/server/test',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getMcpServerTools(id) {
|
||||
return request({
|
||||
url: `/backend/mcp/server/${id}/tools`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// MCP市场
|
||||
export function getMcpMarket() {
|
||||
return request({
|
||||
url: '/backend/mcp/server/market',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function addMcpFromMarket(data) {
|
||||
return request({
|
||||
url: '/backend/mcp/server/from-market',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// ==================== 租户端审批流配置 ====================
|
||||
|
||||
// 编排数据源:当前租户的功能模块 + 审批人候选(用户/角色/部门/职位)
|
||||
export function getApprovalOptions() {
|
||||
return request({
|
||||
url: '/backend/approval/options',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 流程列表(按功能模块过滤,module 即 biz_type)
|
||||
export function getApprovalFlowList(params) {
|
||||
return request({
|
||||
url: '/backend/approval/flow/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 流程详情(含全部节点)
|
||||
export function getApprovalFlowDetail(id) {
|
||||
return request({
|
||||
url: `/backend/approval/flow/detail/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存流程(新建或更新,节点全量覆盖)
|
||||
export function saveApprovalFlow(data) {
|
||||
return request({
|
||||
url: '/backend/approval/flow/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除流程
|
||||
export function deleteApprovalFlow(id) {
|
||||
return request({
|
||||
url: `/backend/approval/flow/delete/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 审批流编号轻量清单(按分类返回启用流程的 flow_code/flow_name,供其它功能按编号调用)
|
||||
export function getApprovalFlowPick(params) {
|
||||
return request({
|
||||
url: '/backend/approval/flow/pick',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 业务侧发起前拉可用流(按当前用户部门过滤)
|
||||
export function getAvailableFlows(params) {
|
||||
return request({ url: '/backend/approval/flow/available', method: 'get', params });
|
||||
}
|
||||
|
||||
// ==================== 审批中心(运行时实例) ====================
|
||||
|
||||
// 我发起的审批实例列表
|
||||
export function getMyApprovalInstances(params) {
|
||||
return request({ url: '/backend/approval/instance/mine', method: 'get', params });
|
||||
}
|
||||
|
||||
// 我作为审批人的任务列表(handled=1 返回我已处理)
|
||||
export function getApprovalTodo(params) {
|
||||
return request({ url: '/backend/approval/instance/todo', method: 'get', params });
|
||||
}
|
||||
|
||||
// 审批实例详情(含流程时间线)
|
||||
export function getApprovalInstanceDetail(id) {
|
||||
return request({ url: `/backend/approval/instance/detail/${id}`, method: 'get' });
|
||||
}
|
||||
|
||||
// 审批人通过 / 驳回
|
||||
export function approveApprovalInstance(data) {
|
||||
return request({ url: '/backend/approval/instance/approve', method: 'post', data });
|
||||
}
|
||||
|
||||
// 发起人撤回
|
||||
export function withdrawApprovalInstance(id) {
|
||||
return request({ url: `/backend/approval/instance/withdraw/${id}`, method: 'post' });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取公司联系人列表
|
||||
export function getCompanyContactList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/company-contact/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 新增联系人
|
||||
export function createCompanyContact(data) {
|
||||
return request({
|
||||
url: '/backend/erp/company-contact',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新联系人
|
||||
export function updateCompanyContact(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/company-contact/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除联系人
|
||||
export function deleteCompanyContact(id) {
|
||||
return request({
|
||||
url: `/backend/erp/company-contact/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 通讯录相关接口
|
||||
|
||||
// 获取通讯录列表
|
||||
export function getContactList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/contact/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取通讯录详情
|
||||
export function getContactDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/contact/detail/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建联系人
|
||||
export function createContact(data) {
|
||||
return request({
|
||||
url: '/backend/erp/contact/create',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新联系人
|
||||
export function updateContact(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/contact/update/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除联系人
|
||||
export function deleteContact(id) {
|
||||
return request({
|
||||
url: `/backend/erp/contact/delete/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 收藏/取消收藏
|
||||
export function starContact(id, starred) {
|
||||
return request({
|
||||
url: `/backend/erp/contact/star/${id}`,
|
||||
method: 'post',
|
||||
data: { is_starred: starred }
|
||||
})
|
||||
}
|
||||
|
||||
// 全量同步员工到通讯录
|
||||
export function syncAllContacts(params) {
|
||||
return request({
|
||||
url: '/backend/erp/contact/syncAll',
|
||||
method: 'post',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取通讯录组织树
|
||||
export function getContactOrgTree(params) {
|
||||
return request({
|
||||
url: '/backend/erp/contact/orgTree',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取组织架构列表
|
||||
export function getOrganizationList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/getOrganization',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取职位列表
|
||||
export function getPositionList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/getPosition',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取职位详情
|
||||
export function getPositionDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/getPositionDetail/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 接口
|
||||
*
|
||||
* 说明:
|
||||
* - 客户 / 供应商沿用已有 ERP 接口(数据模型 TenantCrmCustomer / TenantCrmSupplier,租户隔离);
|
||||
* - 联系人沿用 /backend/crm/contact 系列接口(见 api/contact.js);
|
||||
* - 客户公海为 CRM 新增能力,需后端提供 /backend/crm/pool 系列接口。
|
||||
*/
|
||||
|
||||
/* --------------------------------- 客户 --------------------------------- */
|
||||
|
||||
export function getCrmCustomerList(params) {
|
||||
return request({ url: "/backend/erp/customer/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getCrmCustomerDetail(id) {
|
||||
return request({ url: `/backend/erp/customer/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createCrmCustomer(data) {
|
||||
return request({ url: "/backend/erp/customer", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateCrmCustomer(id, data) {
|
||||
return request({ url: `/backend/erp/customer/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteCrmCustomer(id) {
|
||||
return request({ url: `/backend/erp/customer/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
export function toggleCrmCustomerStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}/status`,
|
||||
method: "post",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户转供应商:把客户档案(含联系人)复制一套到供应商库。
|
||||
* 同一家单位可能既是客户又是供应商,因此原客户记录保留;供应商库已有同名企业时不重复建档,只补同步联系人。
|
||||
*/
|
||||
export function convertCustomerToSupplier(id) {
|
||||
return request({ url: `/backend/erp/customer/${id}/convert-supplier`, method: "post" });
|
||||
}
|
||||
|
||||
/* -------------------------------- 供应商 -------------------------------- */
|
||||
|
||||
export function getCrmSupplierList(params) {
|
||||
return request({ url: "/backend/erp/supplier/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getCrmSupplierDetail(id) {
|
||||
return request({ url: `/backend/erp/supplier/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createCrmSupplier(data) {
|
||||
return request({ url: "/backend/erp/supplier", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateCrmSupplier(id, data) {
|
||||
return request({ url: `/backend/erp/supplier/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteCrmSupplier(id) {
|
||||
return request({ url: `/backend/erp/supplier/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
export function toggleCrmSupplierStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}/status`,
|
||||
method: "post",
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商转客户:把供应商档案(含联系人)复制一套到客户库。
|
||||
* 同一家单位可能既是供应商又是客户,因此原供应商记录保留;客户库已有同名企业时不重复建档,只补同步联系人。
|
||||
*/
|
||||
export function convertSupplierToCustomer(id) {
|
||||
return request({ url: `/backend/erp/supplier/${id}/convert-customer`, method: "post" });
|
||||
}
|
||||
|
||||
/* ------------------------------- 客户公海 ------------------------------- */
|
||||
|
||||
/** 公海列表:租户内共享,所有成员可见 */
|
||||
export function getPoolList(params) {
|
||||
return request({ url: "/backend/crm/pool/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 领取客户:ids 客户ID集合,领取后负责人为当前登录用户 */
|
||||
export function claimPoolCustomer(data) {
|
||||
return request({ url: "/backend/crm/pool/claim", method: "post", data });
|
||||
}
|
||||
|
||||
/** 分配客户:ids 客户ID集合,owner_user_id 负责人 */
|
||||
export function assignPoolCustomer(data) {
|
||||
return request({ url: "/backend/crm/pool/assign", method: "post", data });
|
||||
}
|
||||
|
||||
/** 移入公海:ids 客户ID集合,reason 移入原因 */
|
||||
export function moveToPool(data) {
|
||||
return request({ url: "/backend/crm/pool/move", method: "post", data });
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 招投标管理接口
|
||||
*
|
||||
* 关联类型 related_type:4=投标 5=招标
|
||||
* 投标状态机:0草稿 -> 1立项审批中 -> 2投标中 -> 3已开标 -> (4中标 | 5落标) -> (6已转合同 | 7已归档)
|
||||
* 招标状态机:0草稿 -> 1公告中 -> 2报名中 -> 3投标中 -> 4开标中 -> 5评标中 -> 6已定标 -> 7已归档
|
||||
*/
|
||||
|
||||
/* ====================== 投标(Bid)主表 ====================== */
|
||||
|
||||
export function getBidList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getBidDetail(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid/detail/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createBid(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateBid(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteBid(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 中标转合同 */
|
||||
export function convertBidToContract(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid/${id}/contract`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 中标转项目 */
|
||||
export function convertBidToProject(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid/${id}/project`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 提交开标结果 */
|
||||
export function submitBidResult(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid/${id}/result`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 从商机创建投标 */
|
||||
export function createBidFromBusiness(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid/from-business", method: "post", data });
|
||||
}
|
||||
|
||||
/** 从项目创建投标 */
|
||||
export function createBidFromProject(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid/from-project", method: "post", data });
|
||||
}
|
||||
|
||||
/* ====================== 投标文件 ====================== */
|
||||
|
||||
export function getBidFileList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-file/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidFile(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-file", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteBidFile(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-file/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 投标任务 ====================== */
|
||||
|
||||
export function getBidTaskList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-task/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidTask(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-task", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateBidTask(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid-task/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteBidTask(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-task/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 投标结果 ====================== */
|
||||
|
||||
export function getBidResultList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-result/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getBidResultDetail(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-result/detail/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
/* ====================== 投标报价 ====================== */
|
||||
|
||||
export function getBidQuoteList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-quote/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidQuote(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-quote", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteBidQuote(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-quote/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 投标竞争对手 ====================== */
|
||||
|
||||
export function getBidCompetitorList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-competitor/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidCompetitor(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-competitor", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteBidCompetitor(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-competitor/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 投标费用 ====================== */
|
||||
|
||||
export function getBidExpenseList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-expense/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidExpense(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-expense", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteBidExpense(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-expense/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 招标(Tender)主表 ====================== */
|
||||
|
||||
export function getTenderList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getTenderDetail(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender/detail/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createTender(data) {
|
||||
return request({ url: "/backend/crm/bidding/tender", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateTender(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/tender/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteTender(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 定标 */
|
||||
export function awardTender(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/tender/${id}/award`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 定标转合同 */
|
||||
export function awardTenderToContract(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/tender/${id}/contract`, method: "post", data });
|
||||
}
|
||||
|
||||
/* ====================== 招标文件 ====================== */
|
||||
|
||||
export function getTenderFileList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender-file/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createTenderFile(data) {
|
||||
return request({ url: "/backend/crm/bidding/tender-file", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteTenderFile(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-file/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 招标公告 ====================== */
|
||||
|
||||
export function getTenderAnnounceList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender-announce/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createTenderAnnounce(data) {
|
||||
return request({ url: "/backend/crm/bidding/tender-announce", method: "post", data });
|
||||
}
|
||||
|
||||
export function publishTenderAnnounce(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-announce/${id}/publish`, method: "post" });
|
||||
}
|
||||
|
||||
export function deleteTenderAnnounce(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-announce/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 投标方 / 供应商 ====================== */
|
||||
|
||||
export function getTenderSupplierList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender-supplier/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createTenderSupplier(data) {
|
||||
return request({ url: "/backend/crm/bidding/tender-supplier", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateTenderSupplier(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/tender-supplier/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteTenderSupplier(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-supplier/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 开标评标 ====================== */
|
||||
|
||||
export function getTenderEvaluationList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender-evaluation/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createTenderEvaluation(data) {
|
||||
return request({ url: "/backend/crm/bidding/tender-evaluation", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteTenderEvaluation(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-evaluation/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 定标结果 ====================== */
|
||||
|
||||
export function getTenderResultList(params) {
|
||||
return request({ url: "/backend/crm/bidding/tender-result/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getTenderResultDetail(id) {
|
||||
return request({ url: `/backend/crm/bidding/tender-result/detail/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
/* ====================== 投标立项审批 ====================== */
|
||||
|
||||
export function getBidApprovalList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-approval/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidApproval(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-approval", method: "post", data });
|
||||
}
|
||||
|
||||
export function approveBidApproval(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid-approval/${id}/approve`, method: "post", data });
|
||||
}
|
||||
|
||||
/* ====================== 标书版本 ====================== */
|
||||
|
||||
export function getBidDocumentList(params) {
|
||||
return request({ url: "/backend/crm/bidding/bid-document/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createBidDocument(data) {
|
||||
return request({ url: "/backend/crm/bidding/bid-document", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateBidDocument(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/bid-document/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteBidDocument(id) {
|
||||
return request({ url: `/backend/crm/bidding/bid-document/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 标书模板库 ====================== */
|
||||
|
||||
export function getTemplateList(params) {
|
||||
return request({ url: "/backend/crm/bidding/template/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createTemplate(data) {
|
||||
return request({ url: "/backend/crm/bidding/template", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateTemplate(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/template/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteTemplate(id) {
|
||||
return request({ url: `/backend/crm/bidding/template/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 资质文件库 ====================== */
|
||||
|
||||
export function getQualificationList(params) {
|
||||
return request({ url: "/backend/crm/bidding/qualification/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createQualification(data) {
|
||||
return request({ url: "/backend/crm/bidding/qualification", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateQualification(id, data) {
|
||||
return request({ url: `/backend/crm/bidding/qualification/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteQualification(id) {
|
||||
return request({ url: `/backend/crm/bidding/qualification/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* ====================== 统计 ====================== */
|
||||
|
||||
export function getTenderStatistics(params) {
|
||||
return request({ url: "/backend/crm/bidding/statistics/tender", method: "get", params });
|
||||
}
|
||||
|
||||
export function getBidStatistics(params) {
|
||||
return request({ url: "/backend/crm/bidding/statistics/bid", method: "get", params });
|
||||
}
|
||||
|
||||
/* ====================== 到期提醒(开发计划 4.1) ====================== */
|
||||
|
||||
export function getBiddingReminders(params) {
|
||||
return request({ url: "/backend/crm/bidding/reminders", method: "get", params });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 合同管理接口
|
||||
*
|
||||
* 说明:
|
||||
* - 合同绑定项目:project_id 为空即为「无头合同」,否则为「项目合同」;
|
||||
* - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=异常;
|
||||
* - 参与方 parties / 产品清单 products / 金额汇总 summary 随主表一并以 JSON 提交,
|
||||
* 后端按需落库;step 记录进度式创建所到达的步骤,支持每步保存续填。
|
||||
*/
|
||||
|
||||
/** 合同列表 */
|
||||
export function getContractList(params) {
|
||||
return request({ url: "/backend/crm/contract/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 合同详情 */
|
||||
export function getContractDetail(id) {
|
||||
return request({ url: `/backend/crm/contract/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
/** 创建合同(首次保存草稿,返回 id 后续转为更新) */
|
||||
export function createContract(data) {
|
||||
return request({ url: "/backend/crm/contract", method: "post", data });
|
||||
}
|
||||
|
||||
/** 更新合同(进度式保存每一步都走这里) */
|
||||
export function updateContract(id, data) {
|
||||
return request({ url: `/backend/crm/contract/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
/** 删除合同 */
|
||||
export function deleteContract(id) {
|
||||
return request({ url: `/backend/crm/contract/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=异常 */
|
||||
export function changeContractStatus(id, status) {
|
||||
return request({ url: `/backend/crm/contract/${id}/status`, method: "post", data: { status } });
|
||||
}
|
||||
|
||||
/** 合同统计(列表页顶部卡片) */
|
||||
export function getContractStats(params) {
|
||||
return request({ url: "/backend/crm/contract/stats", method: "get", params });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 数据仪表盘接口
|
||||
*
|
||||
* 说明:
|
||||
* - range 维度:day(天) / week(周) / month(月) / quarter(季度) / year(年);
|
||||
* - 后端按租户 + 时间维度聚合真实业务数据(客户/联系人/项目/合同/线索/商机/回访/回款)。
|
||||
*/
|
||||
|
||||
/** 仪表盘汇总数据 */
|
||||
export function getCrmDashboard(params) {
|
||||
return request({ url: "/backend/crm/dashboard", method: "get", params });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 回款管理接口(针对于合同)
|
||||
*
|
||||
* 说明:
|
||||
* - 回款计划必须挂靠合同(contract_id);
|
||||
* - plan_type 回款周期:1月度/2季度/3年度/4进度/5自定义,决定 items 明细结构;
|
||||
* 月度/季度/年度:{seq,name,amount};
|
||||
* 进度:{seq,name,percent,amount};
|
||||
* 自定义:{seq,name,plan_date,amount};
|
||||
* - pay_method 回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他;
|
||||
* - items 每行带 received_amount/received_date/status,用于回款进度登记。
|
||||
*/
|
||||
|
||||
/** 回款计划列表 */
|
||||
export function getPaybackList(params) {
|
||||
return request({ url: "/backend/crm/payback/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 回款计划详情 */
|
||||
export function getPaybackDetail(id) {
|
||||
return request({ url: `/backend/crm/payback/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
/** 创建回款计划 */
|
||||
export function createPayback(data) {
|
||||
return request({ url: "/backend/crm/payback", method: "post", data });
|
||||
}
|
||||
|
||||
/** 更新回款计划 */
|
||||
export function updatePayback(id, data) {
|
||||
return request({ url: `/backend/crm/payback/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
/** 删除回款计划 */
|
||||
export function deletePayback(id) {
|
||||
return request({ url: `/backend/crm/payback/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 回款进度列表(跨计划摊平的分期明细 + 汇总) */
|
||||
export function getPaybackProgress(params) {
|
||||
return request({ url: "/backend/crm/payback/progress/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 回款进度登记:按明细序号写入实收金额 / 回款日期 */
|
||||
export function registerPaybackReceive(id, data) {
|
||||
return request({ url: `/backend/crm/payback/${id}/receive`, method: "post", data });
|
||||
}
|
||||
|
||||
/* ============================ 回款记录(回款进度流水) ============================
|
||||
* 以「回款计划」为先导条件:每次「新建回款」追加一条记录,构成回款进度列表。
|
||||
* ============================================================================== */
|
||||
|
||||
/** 回款记录列表:按合同 / 计划查询一笔笔实际回款(含计划总额与已回款汇总) */
|
||||
export function getPaybackRecordList(params) {
|
||||
return request({ url: "/backend/crm/payback/record/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 新建回款:追加一条回款记录 */
|
||||
export function createPaybackRecord(data) {
|
||||
return request({ url: "/backend/crm/payback/record", method: "post", data });
|
||||
}
|
||||
|
||||
/** 删除回款记录 */
|
||||
export function deletePaybackRecord(id) {
|
||||
return request({ url: `/backend/crm/payback/record/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 更新回款记录回执(补传流水回执):仅更新回执,不改金额与状态 */
|
||||
export function updatePaybackRecord(id, data) {
|
||||
return request({ url: `/backend/crm/payback/record/${id}`, method: "put", data });
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。
|
||||
*
|
||||
* 关联类型 related_type:1=线索 2=商机 3=项目 4=投标 5=招标 6=合同
|
||||
*/
|
||||
|
||||
/* --------------------------------- 线索 --------------------------------- */
|
||||
|
||||
export function getClueList(params) {
|
||||
return request({ url: "/backend/crm/clue/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getClueDetail(id) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createClue(data) {
|
||||
return request({ url: "/backend/crm/clue", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateClue(id, data) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteClue(id) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 线索转化商机:会把线索客户名称落地为正式客户,并锁定线索 */
|
||||
export function convertClueToBusiness(id, data) {
|
||||
return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索状态流转:1跟进中 / 3已废弃。
|
||||
* 「已转化(2)」不走本接口,须用 convertClueToBusiness 完成(会落地客户并锁定线索)。
|
||||
*/
|
||||
export function changeClueStatus(id, status) {
|
||||
return request({ url: `/backend/crm/clue/${id}/status`, method: "post", data: { status } });
|
||||
}
|
||||
|
||||
/* --------------------------------- 商机 --------------------------------- */
|
||||
|
||||
export function getBusinessList(params) {
|
||||
return request({ url: "/backend/crm/business/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getBusinessDetail(id) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createBusiness(data) {
|
||||
return request({ url: "/backend/crm/business", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateBusiness(id, data) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteBusiness(id) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 商机转化项目 */
|
||||
export function convertBusinessToProject(id, data) {
|
||||
return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单 */
|
||||
export function changeBusinessStage(id, stage) {
|
||||
return request({ url: `/backend/crm/business/${id}/stage`, method: "post", data: { stage } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 商机状态流转:1跟进中 / 3已废弃。
|
||||
* 「已转化项目(2)」不走本接口,须用 convertBusinessToProject 完成(会锁定商机)。
|
||||
*/
|
||||
export function changeBusinessStatus(id, status) {
|
||||
return request({ url: `/backend/crm/business/${id}/status`, method: "post", data: { status } });
|
||||
}
|
||||
|
||||
/* --------------------------------- 项目 --------------------------------- */
|
||||
|
||||
export function getProjectList(params) {
|
||||
return request({ url: "/backend/crm/project/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getProjectDetail(id) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createProject(data) {
|
||||
return request({ url: "/backend/crm/project", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateProject(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteProject(id) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常)。
|
||||
* status=5(异常)时必须传 reason,后端会校验并写入操作日志。
|
||||
*/
|
||||
export function changeProjectStatus(id, status, reason = "") {
|
||||
return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status, reason } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(多选)。
|
||||
* - upstream_contact_ids:上游(客户方)选中的联系人ID数组;
|
||||
* - downstream_contact_ids:下游/中游(对方主体方)选中的联系人ID数组;
|
||||
* - 单一项目后端会自动清空下游集合,传入不属于对应企业的ID会被过滤。
|
||||
*/
|
||||
export function saveProjectContacts(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}/contacts`, method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------- 项目文档(OA 文档库) --------------------------- */
|
||||
|
||||
/**
|
||||
* 项目文档列表:对应文档库「共享文档 / 项目文档 / {项目名称}」文件夹(含子文件夹)。
|
||||
* 存量项目在首次调用时自动补建文件夹。
|
||||
*/
|
||||
export function getProjectDocs(id, params) {
|
||||
return request({ url: `/backend/crm/project/${id}/docs`, method: "get", params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传项目文档:文件先经 POST /backend/uploadfile 上传,
|
||||
* 再调用本接口写入文档库项目文件夹并同步登记 CRM 附件。
|
||||
*/
|
||||
export function uploadProjectDoc(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}/doc-upload`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 删除项目文档:同步软删文档库文档与对应 CRM 附件记录 */
|
||||
export function deleteProjectDoc(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}/doc-delete`, method: "post", data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建项目文档文件夹(对应文档库中项目文件夹下的子分类),用于分类存放文档。
|
||||
* data: { name, parent_id },parent_id 缺省时建在项目根文件夹下。
|
||||
*/
|
||||
export function createProjectDocFolder(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}/doc-folder`, method: "post", data });
|
||||
}
|
||||
|
||||
/** 删除项目文档文件夹(需为空:无子文件夹、无文档;项目根文件夹不可删除) */
|
||||
export function deleteProjectDocFolder(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}/doc-folder-delete`, method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------------- 回访 --------------------------------- */
|
||||
|
||||
export function getFollowList(params) {
|
||||
return request({ url: "/backend/crm/follow/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/edit", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------------- 附件 --------------------------------- */
|
||||
|
||||
export function getAttachList(params) {
|
||||
return request({ url: "/backend/crm/attach/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addAttach(data) {
|
||||
return request({ url: "/backend/crm/attach/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteAttach(data) {
|
||||
return request({ url: "/backend/crm/attach/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* ------------------------------ 联系人(实体) ------------------------------ */
|
||||
|
||||
export function getEntityContactList(params) {
|
||||
return request({ url: "/backend/crm/entity/contact/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/edit", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* ------------------------------- 操作日志 ------------------------------- */
|
||||
|
||||
export function getCrmOperateLogs(params) {
|
||||
return request({ url: "/backend/crm/oplog/list", method: "get", params });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 产品管理(产品台账/目录)接口。
|
||||
*/
|
||||
|
||||
/* --------------------------------- 产品 --------------------------------- */
|
||||
|
||||
export function getProductList(params) {
|
||||
return request({ url: "/backend/crm/product/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getProductDetail(id) {
|
||||
return request({ url: `/backend/crm/product/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createProduct(data) {
|
||||
return request({ url: "/backend/crm/product", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateProduct(id, data) {
|
||||
return request({ url: `/backend/crm/product/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteProduct(id) {
|
||||
return request({ url: `/backend/crm/product/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 项目生成/保存时,把项目产品清单写入产品管理 */
|
||||
export function syncProjectProducts(data) {
|
||||
return request({ url: "/backend/crm/product/sync-from-project", method: "post", data });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 产品分类(用户自定义产品归类)接口。
|
||||
*/
|
||||
|
||||
export function getProductCategoryList(params) {
|
||||
return request({ url: "/backend/crm/product/category/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getProductCategoryDetail(id) {
|
||||
return request({ url: `/backend/crm/product/category/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createProductCategory(data) {
|
||||
return request({ url: "/backend/crm/product/category", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateProductCategory(id, data) {
|
||||
return request({ url: `/backend/crm/product/category/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteProductCategory(id) {
|
||||
return request({ url: `/backend/crm/product/category/${id}`, method: "delete" });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 基本设置 - 计量单位接口。
|
||||
* 单位用于产品管理等业务(产品「单位」下拉取自本表)。
|
||||
*/
|
||||
|
||||
export function getUnitList(params) {
|
||||
return request({ url: "/backend/crm/unit/list", method: "get", params });
|
||||
}
|
||||
|
||||
/** 下拉数据源:仅返回启用中的单位 */
|
||||
export function getAllUnits(params) {
|
||||
return request({ url: "/backend/crm/unit/all", method: "get", params });
|
||||
}
|
||||
|
||||
export function getUnitDetail(id) {
|
||||
return request({ url: `/backend/crm/unit/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createUnit(data) {
|
||||
return request({ url: "/backend/crm/unit", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateUnit(id, data) {
|
||||
return request({ url: `/backend/crm/unit/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteUnit(id) {
|
||||
return request({ url: `/backend/crm/unit/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 补齐缺失的系统默认单位(套 / 台 / 个 …) */
|
||||
export function initDefaultUnits() {
|
||||
return request({ url: "/backend/crm/unit/init-default", method: "post" });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getCustomerList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/customer/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCustomerDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createCustomer(data) {
|
||||
return request({
|
||||
url: '/backend/erp/customer',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomer(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomer(id) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleCustomerStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户转供应商:把客户档案(含联系人)复制一套到供应商库。
|
||||
* 同一家单位可能既是客户又是供应商,因此原客户记录保留;
|
||||
* 供应商库已有同名企业时不重复建档,只补同步联系人。
|
||||
*/
|
||||
export function convertCustomerToSupplier(id) {
|
||||
return request({
|
||||
url: `/backend/erp/customer/${id}/convert-supplier`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
+64
-54
@@ -1,114 +1,124 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取字典类型列表
|
||||
/**
|
||||
* 数据字典接口(租户端)
|
||||
*
|
||||
* 表:yz_backend_dict / yz_backend_dict_item
|
||||
* 租户从 JWT 解析,前端不传 tenant_id。
|
||||
*/
|
||||
const PREFIX = '/backend/dict'
|
||||
|
||||
/* ================= 字典列表(字典类型) ================= */
|
||||
|
||||
/** 字典列表(分页)params: { page, pageSize, keyword, status } */
|
||||
export function getDictTypes(params) {
|
||||
return request({
|
||||
url: '/backend/dict/types',
|
||||
url: `${PREFIX}/types`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取字典类型
|
||||
export function getDictTypeById(id) {
|
||||
/** 新增字典 */
|
||||
export function createDictType(data) {
|
||||
return request({
|
||||
url: `/backend/dict/types/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加字典类型
|
||||
export function addDictType(data) {
|
||||
return request({
|
||||
url: '/backend/dict/types',
|
||||
url: `${PREFIX}/types`,
|
||||
method: 'post',
|
||||
data: {
|
||||
...data,
|
||||
is_global: data.is_global !== undefined ? data.is_global : 0
|
||||
}
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新字典类型
|
||||
/** 编辑字典(dict_code 不可改,由后端忽略) */
|
||||
export function updateDictType(id, data) {
|
||||
return request({
|
||||
url: `/backend/dict/types/${id}`,
|
||||
url: `${PREFIX}/types/${id}`,
|
||||
method: 'put',
|
||||
data: {
|
||||
...data,
|
||||
is_global: data.is_global !== undefined ? data.is_global : 0
|
||||
}
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除字典类型
|
||||
/** 删除字典(其下仍有字典项时后端会拒绝) */
|
||||
export function deleteDictType(id) {
|
||||
return request({
|
||||
url: `/backend/dict/types/${id}`,
|
||||
url: `${PREFIX}/types/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取字典项列表
|
||||
/* ================= 字典项 ================= */
|
||||
|
||||
/** 字典项列表(分页)params: { dict_id, page, pageSize, keyword, status } */
|
||||
export function getDictItems(params) {
|
||||
return request({
|
||||
url: '/backend/dict/items',
|
||||
url: `${PREFIX}/items`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 根据ID获取字典项
|
||||
export function getDictItemById(id) {
|
||||
/** 新增字典项 */
|
||||
export function createDictItem(data) {
|
||||
return request({
|
||||
url: `/backend/dict/items/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加字典项
|
||||
export function addDictItem(data) {
|
||||
return request({
|
||||
url: '/backend/dict/items',
|
||||
url: `${PREFIX}/items`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新字典项
|
||||
/** 编辑字典项 */
|
||||
export function updateDictItem(id, data) {
|
||||
return request({
|
||||
url: `/backend/dict/items/${id}`,
|
||||
url: `${PREFIX}/items/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除字典项
|
||||
/** 启用 / 停用字典项 */
|
||||
export function toggleDictItemStatus(id, status) {
|
||||
return request({
|
||||
url: `${PREFIX}/items/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除字典项 */
|
||||
export function deleteDictItem(id) {
|
||||
return request({
|
||||
url: `/backend/dict/items/${id}`,
|
||||
url: `${PREFIX}/items/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 根据字典编码获取字典项(用于业务查询)
|
||||
export function getDictItemsByCode(code, includeDisabled = false) {
|
||||
/* ================= 业务读取 ================= */
|
||||
|
||||
/** 按字典编码取「字典类型与字典项都启用」的选项,供业务页面下拉使用 */
|
||||
export function getDictOptions(code) {
|
||||
return request({
|
||||
url: `/backend/dict/items/code/${code}`,
|
||||
method: 'get',
|
||||
params: {
|
||||
include_disabled: includeDisabled ? '1' : '0'
|
||||
}
|
||||
url: `${PREFIX}/options/${code}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 批量更新字典项排序
|
||||
export function batchUpdateDictItemSort(data) {
|
||||
/**
|
||||
* 批量取多个字典的选项(一次请求),供页面/应用启动时预加载。
|
||||
* 返回 data.dicts = { [dict_code]: 字典项[] }。
|
||||
* 同编码时后端优先返回本租户自己的字典,没有再回落全局内置字典。
|
||||
* @param {string[]} codes 字典编码数组
|
||||
*/
|
||||
export function getDictOptionsBatch(codes) {
|
||||
return request({
|
||||
url: '/backend/dict/items/sort',
|
||||
method: 'put',
|
||||
data
|
||||
url: `${PREFIX}/options`,
|
||||
method: 'post',
|
||||
data: { codes }
|
||||
})
|
||||
}
|
||||
|
||||
/** 一次取回全部「启用」字典的字典项,供登录后预热缓存 */
|
||||
export function getAllDictOptions() {
|
||||
return request({
|
||||
url: `${PREFIX}/options`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -83,15 +83,32 @@ export function applyTenantDomain(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 审核租户域名
|
||||
export function auditTenantDomain(data) {
|
||||
// 绑定自有域名(提交后需按提示配置解析,再调 checkCustomDns)
|
||||
export function bindCustomDomain(data) {
|
||||
return request({
|
||||
url: '/backend/domain/tenant/audit',
|
||||
url: '/backend/domain/tenant/bindCustom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 检测自有域名解析,通过后后端自动申请 HTTPS 证书
|
||||
export function checkCustomDomainDns(id) {
|
||||
return request({
|
||||
url: '/backend/domain/tenant/checkCustomDns',
|
||||
method: 'post',
|
||||
data: { id }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取自有域名解析配置指引(CNAME 目标 / A 记录 IP)
|
||||
export function getCustomDomainGuide() {
|
||||
return request({
|
||||
url: '/backend/domain/tenant/customDomainGuide',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 禁用/启用租户域名
|
||||
export function toggleTenantDomainStatus(id) {
|
||||
return request({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 员工档案接口(OA 人事档案)
|
||||
*
|
||||
* 档案与员工(yz_backend_employee)一对一;子记录接口统一管理
|
||||
* 教育经历 / 工作经历 / 合同信息 / 证照附件四类记录。
|
||||
* 后端统一返回 { code, data, msg } 包装,调用方需自行取 res.data 使用。
|
||||
*/
|
||||
|
||||
const BASE = '/backend/oa/employeefile';
|
||||
|
||||
/* --------------------------------- 档案 --------------------------------- */
|
||||
|
||||
/** 分页查询档案列表,支持 keyword / status / org_id 筛选 */
|
||||
export function getEmployeeFileList(params) {
|
||||
return request({ url: `${BASE}/list`, method: 'get', params });
|
||||
}
|
||||
|
||||
/** 档案数量统计(总数 + 在职状态分布) */
|
||||
export function getEmployeeFileStats() {
|
||||
return request({ url: `${BASE}/stats`, method: 'get' });
|
||||
}
|
||||
|
||||
/** 档案详情(含员工展示信息与全部子记录) */
|
||||
export function getEmployeeFileDetail(id) {
|
||||
return request({ url: `${BASE}/detail/${id}`, method: 'get' });
|
||||
}
|
||||
|
||||
/** 为员工建档;file_no 留空由后端自动生成 */
|
||||
export function createEmployeeFile(data) {
|
||||
return request({ url: `${BASE}/create`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 更新档案基本资料(不允许修改员工归属) */
|
||||
export function updateEmployeeFile(id, data) {
|
||||
return request({ url: `${BASE}/update/${id}`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 更新已建档员工的学历照片、身份证正反面 */
|
||||
export function updateEmployeeFileCertificates(id, data) {
|
||||
return request({ url: `${BASE}/certificates/${id}`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 删除档案(软删除) */
|
||||
export function deleteEmployeeFile(id) {
|
||||
return request({ url: `${BASE}/delete/${id}`, method: 'delete' });
|
||||
}
|
||||
|
||||
/** 已建档员工ID列表,用于过滤出可建档(未建档)员工 */
|
||||
export function getFiledEmployeeIds() {
|
||||
return request({ url: `${BASE}/filedEmployees`, method: 'get' });
|
||||
}
|
||||
|
||||
/* -------------------------------- 子记录 -------------------------------- */
|
||||
|
||||
/** 新增子记录,type: 1-教育经历 2-工作经历 3-合同信息 4-证照附件 */
|
||||
export function createFileRecord(data) {
|
||||
return request({ url: `${BASE}/record/create`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 更新子记录 */
|
||||
export function updateFileRecord(id, data) {
|
||||
return request({ url: `${BASE}/record/update/${id}`, method: 'post', data });
|
||||
}
|
||||
|
||||
/** 删除子记录(软删除) */
|
||||
export function deleteFileRecord(id) {
|
||||
return request({ url: `${BASE}/record/delete/${id}`, method: 'delete' });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 进销存单据中心接口(/backend/erp/document*)
|
||||
* 采购/销售/库存十类单据共用同一套接口,用 doc_type 区分。
|
||||
*/
|
||||
|
||||
export function getDocumentList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/document/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getDocumentDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 新建或覆盖保存草稿:payload 带 id 即为编辑 */
|
||||
export function saveDocument(data) {
|
||||
return request({
|
||||
url: '/backend/erp/document',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(id) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交审批:flow_id 走查询参数(后端从 query 取),不传则由后端按单据类型挑选审批流 */
|
||||
export function submitDocument(id, params) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}/submit`,
|
||||
method: 'post',
|
||||
params: params || {}
|
||||
})
|
||||
}
|
||||
|
||||
export function approveDocument(id) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}/approve`,
|
||||
method: 'post',
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelDocument(id) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}/cancel`,
|
||||
method: 'post',
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
/** 该单据类型当前可用的审批流,为空时前端只显示「直接审核」 */
|
||||
export function getDocumentFlows(docType) {
|
||||
return request({
|
||||
url: '/backend/erp/document/flows',
|
||||
method: 'get',
|
||||
params: { doc_type: docType }
|
||||
})
|
||||
}
|
||||
|
||||
/** 来源订单/原单中尚可执行的明细行(含 remain_qty),用于按行带出 */
|
||||
export function getExecutableItems(id) {
|
||||
return request({
|
||||
url: `/backend/erp/document/${id}/executable`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 盘点表:带出仓库当前账面数量与加权成本 */
|
||||
export function getTakeSheet(warehouseId) {
|
||||
return request({
|
||||
url: '/backend/erp/document/takeSheet',
|
||||
method: 'get',
|
||||
params: { warehouse_id: warehouseId }
|
||||
})
|
||||
}
|
||||
|
||||
/** 开单页所需字典与默认值(单据类型、状态、默认仓、售价口径、负库存开关) */
|
||||
export function getDocumentMeta() {
|
||||
return request({
|
||||
url: '/backend/erp/document/meta',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 进销存财务接口(/backend/erp/finance*)
|
||||
* 往来账(应收/应付)由已生效单据自动生成,收付款单确认时按单核销或 FIFO 核销。
|
||||
*/
|
||||
|
||||
export function getBillList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/bill/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getBillDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/finance/bill/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getAging(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/aging',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCashFlow(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/cashflow',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 往来单位对账汇总(应收/应付累计与未结) */
|
||||
export function getPartnerSummary(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/partner',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFundList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/fund/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFundDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/finance/fund/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 新建/修改收付款草稿,返回 {id, fund_no} */
|
||||
export function saveFund(data) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/fund',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 确认收付款:body 传 {settles:[{partner_bill_id,amount}]},不传则按账期 FIFO 自动核销 */
|
||||
export function confirmFund(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/finance/fund/${id}/confirm`,
|
||||
method: 'post',
|
||||
data: data || {}
|
||||
})
|
||||
}
|
||||
|
||||
export function voidFund(id) {
|
||||
return request({
|
||||
url: `/backend/erp/finance/fund/${id}/void`,
|
||||
method: 'post',
|
||||
data: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteFund(id) {
|
||||
return request({
|
||||
url: `/backend/erp/finance/fund/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/** 某往来单位当前未结的往来账,供核销勾选 */
|
||||
export function getFundOpenBills(params) {
|
||||
return request({
|
||||
url: '/backend/erp/finance/fund/openBills',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 进销存商品档案接口(/backend/erp/product*)
|
||||
* 商品支持基础单位+辅助单位换算、五级价格、安全/最高库存与保质期批次开关。
|
||||
*/
|
||||
|
||||
export function getProductList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/product/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getProductDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/product/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createProduct(data) {
|
||||
return request({
|
||||
url: '/backend/erp/product',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProduct(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/product/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProduct(id) {
|
||||
return request({
|
||||
url: `/backend/erp/product/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleProductStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/product/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
||||
export function getCategoryList() {
|
||||
return request({
|
||||
url: '/backend/erp/product/category/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCategory(data) {
|
||||
return request({
|
||||
url: '/backend/erp/product/category',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCategory(id) {
|
||||
return request({
|
||||
url: `/backend/erp/product/category/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 进销存报表接口(/backend/erp/report*)
|
||||
* 统计口径只含「已生效」单据与库存流水,因此报表数字与往来账、库存结存永远一致。
|
||||
* 公共参数:date_from、date_to(YYYY-MM-DD,缺省为当月)。
|
||||
*/
|
||||
|
||||
export function getPurchaseSummary(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/purchase',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getSalesSummary(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/sales',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getSalesRank(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/rank',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getStockMoveReport(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/stockmove',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfitReport(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/profit',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getRfmReport(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/rfm',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getErpDashboard(params) {
|
||||
return request({
|
||||
url: '/backend/erp/report/dashboard',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 进销存库存查询接口(/backend/erp/stock*)
|
||||
* 结存全部由流水驱动,这里只有读接口 + 补货建议。
|
||||
*/
|
||||
|
||||
export function getStockList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/stock/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getStockLedger(params) {
|
||||
return request({
|
||||
url: '/backend/erp/stock/ledger',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getStockWarning(params) {
|
||||
return request({
|
||||
url: '/backend/erp/stock/warning',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getReplenishList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/stock/replenish',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getBatchList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/stock/batch',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 进销存仓库档案接口(/backend/erp/warehouse*),列表带库存品种数与货值 */
|
||||
|
||||
export async function getWarehouseList(params) {
|
||||
const res = await request({
|
||||
url: '/backend/erp/warehouse/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
// 后端按 {warehouse, stock_kinds, stock_value} 嵌套返回,拍平后下拉与表格可共用同一行结构
|
||||
if (res && res.code === 200 && Array.isArray(res.data?.list)) {
|
||||
res.data.list = res.data.list.map((row) => ({
|
||||
...(row.warehouse || {}),
|
||||
stock_kinds: row.stock_kinds,
|
||||
stock_value: row.stock_value
|
||||
}))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export function getWarehouseDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/warehouse/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createWarehouse(data) {
|
||||
return request({
|
||||
url: '/backend/erp/warehouse',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateWarehouse(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/warehouse/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteWarehouse(id) {
|
||||
return request({
|
||||
url: `/backend/erp/warehouse/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
+18
-1
@@ -106,10 +106,27 @@ export function getFileById(id) {
|
||||
*/
|
||||
export function uploadFile(formData, options = {}) {
|
||||
// 0 表示「未分类」,不能用 truthy 判断
|
||||
if (options.cate !== undefined && options.cate !== null && options.cate !== "") {
|
||||
// 调用方若已手动 append 过 cate,这里不再重复追加(避免 multipart 出现两个 cate)
|
||||
if (
|
||||
options.cate !== undefined &&
|
||||
options.cate !== null &&
|
||||
options.cate !== "" &&
|
||||
!formData.has("cate")
|
||||
) {
|
||||
formData.append("cate", String(options.cate));
|
||||
}
|
||||
|
||||
// scope: 'user' 表示上传到「我的个人文件」目录(backend/{tid}/{uid}/),
|
||||
// 不传或 'tenant' 表示租户共享文件(backend/{tid}/)
|
||||
if (options.scope && !formData.has("scope")) {
|
||||
formData.append("scope", String(options.scope));
|
||||
}
|
||||
|
||||
// tuid:显式指定归属用户(代他人上传场景),一般不需要传
|
||||
if (options.tuid && !formData.has("tuid")) {
|
||||
formData.append("tuid", String(options.tuid));
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: "/backend/uploadfile",
|
||||
method: "post",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 职级管理接口
|
||||
*
|
||||
* 职级(Job Rank)定义「职位等级」:每个部门可以各设一套职级阶梯,
|
||||
* department_id = 0 表示全公司通用职级,任何部门都能选用。
|
||||
*
|
||||
* 数据按租户隔离:后端一律从 JWT 里取 tid,前端传的 tenant_id 一律忽略。
|
||||
*/
|
||||
|
||||
/** 职级列表。scope=assign 时返回「该部门可用」的职级(部门自身 + 全公司通用) */
|
||||
export function getJobRankList(params = {}) {
|
||||
return request({
|
||||
url: '/backend/jobrank/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getJobRankDetail(id) {
|
||||
return request({
|
||||
url: `/backend/jobrank/detail/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function createJobRank(data) {
|
||||
return request({
|
||||
url: '/backend/jobrank/create',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateJobRank(id, data) {
|
||||
return request({
|
||||
url: `/backend/jobrank/update/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteJobRank(id) {
|
||||
return request({
|
||||
url: `/backend/jobrank/delete/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/** 职级编码唯一性校验(excludeId 为编辑时要排除的自身) */
|
||||
export function checkJobRankCodeUnique(rankCode, excludeId) {
|
||||
return request({
|
||||
url: '/backend/jobrank/checkCodeUnique',
|
||||
method: 'get',
|
||||
params: { rank_code: rankCode, exclude_id: excludeId || '' },
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getJobRankList,
|
||||
getJobRankDetail,
|
||||
createJobRank,
|
||||
updateJobRank,
|
||||
deleteJobRank,
|
||||
checkJobRankCodeUnique,
|
||||
};
|
||||
@@ -80,6 +80,22 @@ export function getOpenVerify() {
|
||||
});
|
||||
}
|
||||
|
||||
// 获取当前登录用户信息(含租户名称)
|
||||
export function getCurrentUser() {
|
||||
return request({
|
||||
url: '/backend/getCurrentUser',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取当前登录用户的角色 / 部门 / 职位
|
||||
export function getCurrentUserProfile() {
|
||||
return request({
|
||||
url: '/backend/getCurrentUserProfile',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册
|
||||
export function register(data) {
|
||||
return request({
|
||||
|
||||
+12
-1
@@ -1,10 +1,21 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取所有菜单
|
||||
export function getAllMenus() {
|
||||
// params.cid=2 时仅返回租户端(backend)菜单,用于角色权限编辑
|
||||
export function getAllMenus(params = {}) {
|
||||
return request({
|
||||
url: `/backend/allmenu`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取可分配给角色的菜单(服务端已过滤停用/隐藏菜单,cid=2 时为租户端菜单)
|
||||
export function getAssignableMenus(params = {}) {
|
||||
return request({
|
||||
url: `/backend/assignableMenus`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getNormalSettings() {
|
||||
return request({
|
||||
url: '/backend/erp/normal-settings',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveNormalSettings(data) {
|
||||
return request({
|
||||
url: '/backend/erp/normal-settings',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function resetNormalSettings() {
|
||||
return request({
|
||||
url: '/backend/erp/normal-settings/reset',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取当前用户的通知配置
|
||||
export function getNotifyConfig() {
|
||||
return request({
|
||||
url: '/backend/notify-config',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新当前用户的通知配置
|
||||
export function updateNotifyConfig(data) {
|
||||
return request({
|
||||
url: '/backend/notify-config',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 考勤打卡:后台只做记录查看与备注,打卡动作在移动端(/api/oa/attendance/*)
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
// 考勤记录:start_date/end_date 必传或由后端默认本月
|
||||
// keyword 支持姓名/账号,org_id + include_child 按部门过滤,status 为异常口径
|
||||
export function getAttendanceRecords(params) {
|
||||
return request({ url: '/backend/oa/attendance/records', method: 'get', params })
|
||||
}
|
||||
|
||||
// 全员考勤汇总:period=month|quarter|year,date 为锚点日期(默认今天)
|
||||
// 返回 {period,label,year,start_date,end_date,active_key,stats,cards:{months,quarters,year},list,total}
|
||||
export function getAttendanceSummary(params) {
|
||||
return request({ url: '/backend/oa/attendance/summary', method: 'get', params })
|
||||
}
|
||||
|
||||
// 缺卡名单:指定日期有排班但未签到的人
|
||||
export function getAttendanceMissing(params) {
|
||||
return request({ url: '/backend/oa/attendance/missing', method: 'get', params })
|
||||
}
|
||||
|
||||
// 管理员备注 / 异常说明
|
||||
export function updateAttendanceRemark(id, remark) {
|
||||
return request({ url: `/backend/oa/attendance/remark/${id}`, method: 'post', data: { remark } })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 薪酬管理
|
||||
|
||||
export function getCompensationEmployees(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/employees',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCompensationDashboard(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/dashboard',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getCompensationSchemes(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/schemes',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createCompensationScheme(data) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/schemes',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompensationScheme(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/schemes/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCompensationScheme(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/schemes/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function getPayrollList(params) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/payrolls',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getPayrollDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createPayroll(data) {
|
||||
return request({
|
||||
url: '/backend/oa/compensation/payrolls',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePayroll(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePayroll(id) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePayrollStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/oa/compensation/payrolls/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 工作日报
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
// 当前用户身份与可见范围:{ user_id, org_id, is_admin, can_view_team, window_start, window_end, backfill_days }
|
||||
export function getDailyReportProfile() {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/profile',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 我的日报列表:params = { page, pageSize, start_date, end_date }
|
||||
// data = { list, total, summary: { month_total, month_submitted, today_status, today_report_id, window_start, window_end } }
|
||||
export function getMyDailyReports(params) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/mine/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 我的日报近期热力图:params = { months }(默认 3,含本月往前数)
|
||||
// data = { start_date, end_date, total, submitted, days: [{ date, status, id }] }
|
||||
export function getMyDailyReportCalendar(params) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/calendar',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 保存日报:{ date, today_done, tomorrow_plan, issues, attachments, submit }
|
||||
// submit=true 提交,false 存草稿;同一人同一天只会有一条记录
|
||||
// 注意:已提交的日报会被锁定,需先调用 withdrawDailyReport 撤销提交后才能修改
|
||||
export function saveDailyReport(data) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/save',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 撤销提交:已提交日报退回草稿,同时把本次提交的内容快照写入修改痕迹,改完需重新提交
|
||||
export function withdrawDailyReport(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/withdraw/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 日报详情:{ report, comments, histories, is_author, can_edit, can_delete, can_comment, can_withdraw }
|
||||
// can_edit 仅在「本人草稿且处于补写窗口」时为 true;can_withdraw 仅在已提交且可撤销时为 true
|
||||
export function getDailyReportDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/detail/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDailyReport(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/delete/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 团队汇总:params = { date, org_id, keyword, page, pageSize }
|
||||
// data = { date, list, total, stats: { total, submitted, missing, rate } }
|
||||
export function getTeamDailyReports(params) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/team/status',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 团队筛选部门:data = { list: [{ id, parent_id, name, member_count }] }
|
||||
export function getTeamDailyReportOrgs() {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/team/orgs',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 评论 / 回复:{ report_id, content, reply_to_id }
|
||||
export function addDailyReportComment(data) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/comment',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDailyReportComment(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/comment/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// OA 文档管理(文档库 / 文档图谱)
|
||||
// 后端统一返回 { code, msg, data },响应拦截器只剥离外层,调用方需自行取 res.data
|
||||
|
||||
// ---------------- 分类 ----------------
|
||||
|
||||
// 分类树(每个节点带 doc_count),全租户共用一套,不再区分个人/共享
|
||||
export function getDocCategories(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/categories",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function createDocCategory(data) {
|
||||
return request({
|
||||
url: "/backend/oa/document/categories",
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDocCategory(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/document/categories/${id}`,
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocCategory(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/categories/${id}`,
|
||||
method: "delete"
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 文档 ----------------
|
||||
|
||||
export function getDocList(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/list",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 概览统计:可见性维度(不再区分个人/共享)
|
||||
export function getDocStats(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/stats",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 详情(后端会累加查看次数)
|
||||
export function getDocDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/detail/${id}`,
|
||||
method: "get"
|
||||
});
|
||||
}
|
||||
|
||||
// 在线文档编辑源(不累加查看次数),用于打开在线编辑器回填内容
|
||||
export function getDocContent(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/content/${id}`,
|
||||
method: "get"
|
||||
});
|
||||
}
|
||||
|
||||
export function createDoc(data) {
|
||||
return request({
|
||||
url: "/backend/oa/document/create",
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDoc(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/document/update/${id}`,
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDoc(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/delete/${id}`,
|
||||
method: "delete"
|
||||
});
|
||||
}
|
||||
|
||||
export function batchDeleteDocs(ids) {
|
||||
return request({
|
||||
url: "/backend/oa/document/delete-batch",
|
||||
method: "post",
|
||||
data: { ids }
|
||||
});
|
||||
}
|
||||
|
||||
export function moveDocs(ids, categoryId) {
|
||||
return request({
|
||||
url: "/backend/oa/document/move",
|
||||
method: "post",
|
||||
data: { ids, category_id: categoryId }
|
||||
});
|
||||
}
|
||||
|
||||
// 收藏 / 取消收藏
|
||||
export function toggleDocStar(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/star/${id}`,
|
||||
method: "post"
|
||||
});
|
||||
}
|
||||
|
||||
// 记录下载次数并取回下载地址
|
||||
export function recordDocDownload(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/download/${id}`,
|
||||
method: "post"
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 文档关联 ----------------
|
||||
|
||||
export function getDocLinks(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/links",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function createDocLink(data) {
|
||||
return request({
|
||||
url: "/backend/oa/document/links",
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocLink(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/links/${id}`,
|
||||
method: "delete"
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 文档私密共享 ----------------
|
||||
|
||||
// 租户用户列表(私密文档共享选人)
|
||||
export function getDocMembers(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/members",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 读取文档共享清单
|
||||
export function getDocShares(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/shares/${id}`,
|
||||
method: "get"
|
||||
});
|
||||
}
|
||||
|
||||
// 全量设置文档共享(shares: [{ share_type, target_id }])
|
||||
export function setDocShares(id, shares) {
|
||||
return request({
|
||||
url: `/backend/oa/document/shares/${id}`,
|
||||
method: "post",
|
||||
data: { shares }
|
||||
});
|
||||
}
|
||||
|
||||
// 删除单条共享
|
||||
export function removeDocShare(sid) {
|
||||
return request({
|
||||
url: `/backend/oa/document/share/${sid}`,
|
||||
method: "delete"
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 文档图谱 ----------------
|
||||
|
||||
export function getDocGraph(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/graph",
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 通知公告
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
export function getNoticeList(params) {
|
||||
return request({
|
||||
url: '/backend/oa/notice/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 工作台/仪表盘用:已发布公告,置顶优先
|
||||
export function getNoticePortal(params) {
|
||||
return request({
|
||||
url: '/backend/oa/notice/portal',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getNoticeDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/notice/detail/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotice(data) {
|
||||
return request({
|
||||
url: '/backend/oa/notice/create',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNotice(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/notice/update/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNotice(id) {
|
||||
return request({
|
||||
url: `/backend/oa/notice/delete/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 发布 / 下架切换
|
||||
export function publishNotice(id) {
|
||||
return request({
|
||||
url: `/backend/oa/notice/publish/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 置顶 / 取消置顶切换
|
||||
export function topNotice(id) {
|
||||
return request({
|
||||
url: `/backend/oa/notice/top/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 日程管理(日历待办)
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
export function getScheduleList(params) {
|
||||
return request({
|
||||
url: '/backend/oa/schedule/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getScheduleStats() {
|
||||
return request({
|
||||
url: '/backend/oa/schedule/stats',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createSchedule(data) {
|
||||
return request({
|
||||
url: '/backend/oa/schedule/create',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSchedule(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/update/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSchedule(id) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/delete/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function finishSchedule(id) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/finish/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 顺延单条日程到下一天(target_date 为空时后端默认取当前日程日期 +1 天)
|
||||
export function carrySchedule(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/carry/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新日程的相关图片:images 为 [{id, url, name}],由后端序列化存储
|
||||
export function updateScheduleImages(id, images) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/images/${id}`,
|
||||
method: 'post',
|
||||
data: { images }
|
||||
})
|
||||
}
|
||||
|
||||
// 仅更新日程的备注内容(创建 / 修改 / 完成后均可),不会影响其他字段或已绑定提醒
|
||||
export function updateScheduleContent(id, content) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/content/${id}`,
|
||||
method: 'post',
|
||||
data: { content }
|
||||
})
|
||||
}
|
||||
|
||||
// 批量顺延指定日期的全部未完成日程
|
||||
export function carryPendingSchedules(data) {
|
||||
return request({
|
||||
url: '/backend/oa/schedule/carry-pending',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 中国法定节假日/调休日
|
||||
// 返回 { list: [{date, name, type, year, wage}], builtin_years: [..] }
|
||||
// type: 0 调休上班(周末补班,显示「班」);1 法定放假(显示「休」)
|
||||
// 传 start_date + end_date 查区间(日历按 42 格查询),或传 year 查整年。
|
||||
export function getScheduleHolidays(params) {
|
||||
return request({
|
||||
url: '/backend/oa/holiday',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 工作排班:班次 / 排班方案 / 排班看板 / 临时调配
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
/* ------------------------------- 班次 ------------------------------- */
|
||||
|
||||
export function getShiftList() {
|
||||
return request({ url: '/backend/oa/scheduling/shifts', method: 'get' })
|
||||
}
|
||||
|
||||
export function createShift(data) {
|
||||
return request({ url: '/backend/oa/scheduling/shifts', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateShift(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function deleteShift(id) {
|
||||
return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
/* ------------------------------- 员工 ------------------------------- */
|
||||
|
||||
// 排班可选员工:org_id 为空表示全部,include_child=1 含下级组织
|
||||
export function getSchedulingEmployees(params) {
|
||||
return request({ url: '/backend/oa/scheduling/employees', method: 'get', params })
|
||||
}
|
||||
|
||||
/* ------------------------------- 看板 ------------------------------- */
|
||||
|
||||
export function getScheduleBoard(params) {
|
||||
return request({ url: '/backend/oa/scheduling/board', method: 'get', params })
|
||||
}
|
||||
|
||||
// 当前登录用户的排班(按账号匹配员工档案),供日程日历显示上班/休息背景
|
||||
export function getMyScheduleBoard(params) {
|
||||
return request({ url: '/backend/oa/scheduling/my', method: 'get', params })
|
||||
}
|
||||
|
||||
// 批量按格子编辑排班:cells 为排班板多选的「员工 × 日期」任意组合
|
||||
// shift_id>0 设为该班次(有排班=调班,无排班=新增);shift_id=0 清除(休息/请假)
|
||||
export function saveScheduleEntryCells(data) {
|
||||
return request({ url: '/backend/oa/scheduling/entries/save-cells', method: 'post', data })
|
||||
}
|
||||
|
||||
/* ----------------------------- 排班模板 ----------------------------- */
|
||||
|
||||
// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案
|
||||
export function getSchedTemplates(params) {
|
||||
return request({ url: '/backend/oa/scheduling/templates', method: 'get', params })
|
||||
}
|
||||
|
||||
// 调用模板:自动匹配 / 创建租户自己的班次,返回可直接用于方案的 pattern
|
||||
export function applySchedTemplate(templateId) {
|
||||
return request({ url: '/backend/oa/scheduling/templates/apply', method: 'post', data: { template_id: templateId } })
|
||||
}
|
||||
|
||||
/* ------------------------------- 方案 ------------------------------- */
|
||||
|
||||
export function getSchedulePlans(params) {
|
||||
return request({ url: '/backend/oa/scheduling/plans', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getSchedulePlanDetail(id) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/detail/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
// 预览方案覆盖人数与生成条数,不写入数据
|
||||
export function previewSchedulePlan(data) {
|
||||
return request({ url: '/backend/oa/scheduling/plans/preview', method: 'post', data })
|
||||
}
|
||||
|
||||
export function createSchedulePlan(data) {
|
||||
return request({ url: '/backend/oa/scheduling/plans/create', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateSchedulePlan(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/update/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
// overwrite=true 覆盖同一员工同一天已有排班,默认保留
|
||||
export function publishSchedulePlan(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/publish/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
// delete_entries=0 时仅删除方案,保留已生成的排班
|
||||
export function deleteSchedulePlan(id, params) {
|
||||
return request({ url: `/backend/oa/scheduling/plans/delete/${id}`, method: 'delete', params })
|
||||
}
|
||||
|
||||
/* ----------------------------- 明细与调配 ----------------------------- */
|
||||
|
||||
// 临时排班:employee_ids + start_date/end_date + weekdays + shift_id
|
||||
// shift_id=0 表示清除这些日期的排班
|
||||
export function saveScheduleEntries(data) {
|
||||
return request({ url: '/backend/oa/scheduling/entries/save', method: 'post', data })
|
||||
}
|
||||
|
||||
export function swapScheduleEntry(id, data) {
|
||||
return request({ url: `/backend/oa/scheduling/entries/swap/${id}`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function clearScheduleEntries(data) {
|
||||
return request({ url: '/backend/oa/scheduling/entries/clear', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getScheduleChanges(params) {
|
||||
return request({ url: '/backend/oa/scheduling/changes', method: 'get', params })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** OCR 识别(配置在平台「AI管理 → OCR识别配置」) */
|
||||
export function ocrRecognize(data) {
|
||||
return request({ url: "/backend/ocr/recognize", method: "post", data });
|
||||
}
|
||||
@@ -51,13 +51,13 @@ export function deleteOnePage(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径获取单页(前端使用)
|
||||
* 根据路径获取单页(后端租户端独立接口)
|
||||
* @param {string} path 路由路径
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getOnePageByPath(path) {
|
||||
return request({
|
||||
url: `/index/onepage/${encodeURIComponent(path)}`,
|
||||
url: `/backend/onepage/path/${encodeURIComponent(path)}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@ export const getPasswordStoreDetail = (id) => request({ url: `${resource}/detail
|
||||
export const createPasswordStore = (data) => request({ url: `${resource}/create`, method: 'post', data })
|
||||
export const updatePasswordStore = (id, data) => request({ url: `${resource}/update/${id}`, method: 'post', data })
|
||||
export const deletePasswordStore = (id) => request({ url: `${resource}/delete/${id}`, method: 'delete' })
|
||||
/** 导出全部记录(不受列表 200 条限制),返回 data.list */
|
||||
export const exportPasswordStore = () => request({ url: `${resource}/export`, method: 'get' })
|
||||
/** 批量导入:rows 为 Excel/CSV 解析后的行数据,返回 created/updated/failed/failures */
|
||||
export const importPasswordStore = (rows) => request({ url: `${resource}/import`, method: 'post', data: { rows } })
|
||||
@@ -0,0 +1,86 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/*************************************************
|
||||
* 租户端「产品购买」相关接口
|
||||
*************************************************/
|
||||
|
||||
/** 可购买产品列表(含功能节点与价格档位) */
|
||||
export function getProductList(params) {
|
||||
return request({
|
||||
url: "/backend/product/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 按产品编码取详情 */
|
||||
export function getProductDetailByCode(code) {
|
||||
return request({
|
||||
url: `/backend/product/detail/${code}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 下单(channel 为空表示线下/待平台开通) */
|
||||
export function createProductOrder(data) {
|
||||
return request({
|
||||
url: "/backend/product/order/create",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询订单状态(已支付会自动开通) */
|
||||
export function getProductOrderStatus(orderNo) {
|
||||
return request({
|
||||
url: "/backend/product/order/status",
|
||||
method: "get",
|
||||
params: { order_no: orderNo },
|
||||
});
|
||||
}
|
||||
|
||||
/** 本租户购买记录 */
|
||||
export function getMyProductOrders() {
|
||||
return request({
|
||||
url: "/backend/product/order/list",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 收银台可用支付渠道 */
|
||||
export function getPaymentChannels() {
|
||||
return request({
|
||||
url: "/backend/payment/channels",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/*************************************************
|
||||
* 租户端「套餐购买」相关接口
|
||||
*************************************************/
|
||||
|
||||
/** 可购买套餐列表(仅启用的套餐) */
|
||||
export function getPackageList(params) {
|
||||
return request({
|
||||
url: "/backend/package/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 按套餐编码取详情 */
|
||||
export function getPackageDetailByCode(code) {
|
||||
return request({
|
||||
url: `/backend/package/detail/${code}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 套餐下单(channel 为空表示线下/待平台开通) */
|
||||
export function createPackageOrder(data) {
|
||||
return request({
|
||||
url: "/backend/package/order/create",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -45,10 +45,20 @@ export function deleteReimbursement(id) {
|
||||
})
|
||||
}
|
||||
|
||||
export function submitReimbursement(id) {
|
||||
export function submitReimbursement(id, comment = '') {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/submit`,
|
||||
method: 'post'
|
||||
method: 'post',
|
||||
data: { comment }
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回审批中的报销单:撤掉运行中的审批实例并回到草稿,撤回后可继续编辑、再次提交
|
||||
export function withdrawReimbursement(id, comment = '') {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/withdraw`,
|
||||
method: 'post',
|
||||
data: { comment }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +70,16 @@ export function updateReimbursementStatus(id, data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 可挂靠发票:只返回「当前报销单」内上传、票面金额已维护且仍有余额的发票
|
||||
// reimbursement_id 必填;exclude_item_id 用于编辑明细时排除它自身的占用
|
||||
export function getAvailableInvoices(params) {
|
||||
return request({
|
||||
url: '/backend/reimbursements/invoices/available',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getReimbursementRecords(id) {
|
||||
return request({
|
||||
url: `/backend/reimbursements/${id}/records`,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 平台「小程序配置」中已启用的小程序(含二维码地址,公开接口无需登录)
|
||||
export function getSiteMiniApps() {
|
||||
return request({
|
||||
url: '/site/miniapp',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 后台顶栏品牌(站点名 / Logo,数据取自平台「官网管理 - 站点设置」)
|
||||
export function getBackendBrand() {
|
||||
return request({
|
||||
url: '/backend/brand',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSupplierList(params) {
|
||||
return request({
|
||||
url: '/backend/erp/supplier/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getSupplierDetail(id) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createSupplier(data) {
|
||||
return request({
|
||||
url: '/backend/erp/supplier',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSupplier(id, data) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSupplier(id) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleSupplierStatus(id, status) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}/status`,
|
||||
method: 'post',
|
||||
data: { status }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商转客户:把供应商档案(含联系人)复制一套到客户库。
|
||||
* 同一家单位可能既是供应商又是客户,因此原供应商记录保留;
|
||||
* 客户库已有同名企业时不重复建档,只补同步联系人。
|
||||
*/
|
||||
export function convertSupplierToCustomer(id) {
|
||||
return request({
|
||||
url: `/backend/erp/supplier/${id}/convert-customer`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -81,4 +81,15 @@ export function checkTenantCode(tenant_code) {
|
||||
method: 'get',
|
||||
params: { tenant_code }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前租户的用户数配额(套餐、已用/上限、剩余)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getTenantQuotaInfo() {
|
||||
return request({
|
||||
url: '/backend/tenant/quotaInfo',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 获取「加入企业」申请列表(仅本租户,按 JWT tid 隔离)
|
||||
export function getTenantApplies(params) {
|
||||
return request({
|
||||
url: '/backend/tenantApplies',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 通过申请:申请人加入本企业(默认「租户用户」角色)
|
||||
export function approveTenantApply(id) {
|
||||
return request({
|
||||
url: `/backend/tenantApplies/approve/${id}`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
// 驳回申请:data = { reason }
|
||||
export function rejectTenantApply(id, data) {
|
||||
return request({
|
||||
url: `/backend/tenantApplies/reject/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 租户后台用户管理(yz_system_tenant_user),走后端租户端 /backend/* 独立接口
|
||||
// (BackendAdminUserController 直接操作 SystemTenantUser,租户隔离由 JWT 完成)
|
||||
export function getTenantUserList(params) {
|
||||
return request({
|
||||
url: '/backend/getAllUsers',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getTenantUsersByTid(tid) {
|
||||
return request({
|
||||
url: `/backend/getTenantUsers/${tid}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
export function createTenantUser(data) {
|
||||
return request({
|
||||
url: '/backend/addUser',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function editTenantUser(id, data) {
|
||||
return request({
|
||||
url: `/backend/editUser/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTenantUser(id) {
|
||||
return request({
|
||||
url: `/backend/deleteUser/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
}
|
||||
+111
-84
@@ -1,84 +1,111 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
//获取所有用户信息
|
||||
export function getAllUsers(params) {
|
||||
return request({
|
||||
url: '/backend/getAllUsers',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
//获取租户用户
|
||||
export function getTenantUsers(tenantId) {
|
||||
return request({
|
||||
url: `/backend/getTenantUsers/${tenantId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo(userId) {
|
||||
return request({
|
||||
url: `/backend/getUserInfo/${userId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
export function addUser(data) {
|
||||
return request({
|
||||
url: '/backend/addUser',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑用户信息
|
||||
export function editUser(userId, data) {
|
||||
return request({
|
||||
url: `/backend/editUser/${userId}`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户信息(编辑用户的别名)
|
||||
export function updateUserInfo(userId, data) {
|
||||
return editUser(userId, data);
|
||||
}
|
||||
|
||||
// 更新个人资料
|
||||
export function updateUserProfile(userId, data) {
|
||||
return editUser(userId, data);
|
||||
}
|
||||
|
||||
// 绑定/更换手机号
|
||||
export function bindPhone(userId, phone) {
|
||||
return editUser(userId, { phone });
|
||||
}
|
||||
|
||||
// 绑定/更换邮箱
|
||||
export function bindEmail(userId, email) {
|
||||
return editUser(userId, { email });
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function deleteUser(userId) {
|
||||
return request({
|
||||
url: `/backend/deleteUser/${userId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePassword(userId, data) {
|
||||
return request({
|
||||
url: '/backend/changePassword',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: userId,
|
||||
password: data.newPassword
|
||||
},
|
||||
});
|
||||
}
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 将所选租户用户与人员档案进行比对,有差异时同步人员信息
|
||||
export function syncTenantUsersToEmployees(userIds) {
|
||||
return request({
|
||||
url: "/backend/syncTenantUsersToEmployees",
|
||||
method: "post",
|
||||
data: { user_ids: userIds },
|
||||
});
|
||||
}
|
||||
|
||||
//获取所有用户信息
|
||||
export function getAllUsers(params) {
|
||||
return request({
|
||||
url: '/backend/getAllUsers',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
//获取租户用户
|
||||
export function getTenantUsers(tenantId) {
|
||||
return request({
|
||||
url: `/backend/getTenantUsers/${tenantId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
// params.by === 'id':按 yz_system_tenant_user 主键精确查询(用户管理页传列表行 id 时必须携带);
|
||||
// 缺省时后端兼容 identity_id / 老 uid(个人中心等场景)
|
||||
export function getUserInfo(userId, params) {
|
||||
return request({
|
||||
url: `/backend/getUserInfo/${userId}`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
export function addUser(data) {
|
||||
return request({
|
||||
url: '/backend/addUser',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑用户信息(params.by === 'id' 时后端按主键精确匹配)
|
||||
export function editUser(userId, data, params) {
|
||||
return request({
|
||||
url: `/backend/editUser/${userId}`,
|
||||
method: 'post',
|
||||
data,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户信息(编辑用户的别名)
|
||||
export function updateUserInfo(userId, data) {
|
||||
return editUser(userId, data);
|
||||
}
|
||||
|
||||
// 更新个人资料
|
||||
export function updateUserProfile(userId, data) {
|
||||
return editUser(userId, data);
|
||||
}
|
||||
|
||||
// 绑定/更换手机号
|
||||
export function bindPhone(userId, phone) {
|
||||
return editUser(userId, { phone });
|
||||
}
|
||||
|
||||
// 绑定/更换邮箱
|
||||
export function bindEmail(userId, email) {
|
||||
return editUser(userId, { email });
|
||||
}
|
||||
|
||||
// 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像)
|
||||
// formData 需包含 file 字段;返回 data.url 为头像地址
|
||||
export function uploadAvatar(formData) {
|
||||
return request({
|
||||
url: '/backend/uploadavatar',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
// 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary
|
||||
timeout: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除用户(params.by === 'id' 时后端按主键精确匹配)
|
||||
export function deleteUser(userId, params) {
|
||||
return request({
|
||||
url: `/backend/deleteUser/${userId}`,
|
||||
method: 'delete',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改密码;byID=true 表示 userId 是用户管理列表行主键,后端按主键精确匹配
|
||||
export function changePassword(userId, data, byID = false) {
|
||||
return request({
|
||||
url: '/backend/changePassword',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: userId,
|
||||
password: data.newPassword,
|
||||
by: byID ? 'id' : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/*************************************************
|
||||
* 微信公众号绑定(租户端)
|
||||
* 流程:生成二维码 → 微信扫码关注 → 公众号回复验证码 → 输入验证码完成绑定
|
||||
*************************************************/
|
||||
|
||||
/** 发起绑定:返回绑定指引(未认证公众号无带参二维码,公众号内发送「验证码」取码) */
|
||||
export function startWechatMpBind() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/start",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询绑定会话状态:0待扫码 1已扫码待核销 2已核销 3已过期 */
|
||||
export function getWechatMpBindStatus(scene) {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/status",
|
||||
method: "get",
|
||||
params: { scene },
|
||||
});
|
||||
}
|
||||
|
||||
/** 核销验证码完成绑定 */
|
||||
export function confirmWechatMpBind(data) {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/confirm",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 当前租户用户的绑定信息 */
|
||||
export function getMyWechatMpBinding() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/my",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除当前用户的微信绑定 */
|
||||
export function unbindWechatMp() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/unbind",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,51 @@
|
||||
// Element Plus Message z-index
|
||||
// 全局主题:浅蓝白后台框架
|
||||
// 自定义变量供顶栏 / 侧栏 / 内容区统一取色,Element Plus 主色同步收敛。
|
||||
:root {
|
||||
--el-message-z-index: 9999;
|
||||
|
||||
--el-color-primary: #2f6bff;
|
||||
--el-color-primary-light-3: #6f97ff;
|
||||
--el-color-primary-light-5: #97b5ff;
|
||||
--el-color-primary-light-7: #c5d8ff;
|
||||
--el-color-primary-light-8: #dce7ff;
|
||||
--el-color-primary-light-9: #eef4ff;
|
||||
--el-color-primary-dark-2: #2555cc;
|
||||
|
||||
--yz-primary: #2f6bff;
|
||||
--yz-primary-soft: #eef4ff;
|
||||
--yz-primary-softer: #f6f9ff;
|
||||
--yz-header-bg: #ffffff;
|
||||
--yz-aside-bg: #ffffff;
|
||||
--yz-page-bg: #f4f7fd;
|
||||
--yz-card-bg: #ffffff;
|
||||
--yz-border: #e8edf7;
|
||||
--yz-text: #24304a;
|
||||
--yz-text-regular: #5b6785;
|
||||
--yz-text-muted: #93a0b8;
|
||||
--yz-shadow: 0 2px 12px rgba(31, 61, 130, 0.06);
|
||||
--yz-header-height: 64px;
|
||||
}
|
||||
|
||||
// 暗色模式:只覆盖框架自定义变量,Element Plus 变量交给官方 dark css-vars
|
||||
html.dark {
|
||||
--yz-primary: #6f97ff;
|
||||
--yz-primary-soft: rgba(64, 110, 220, 0.2);
|
||||
--yz-primary-softer: rgba(64, 110, 220, 0.12);
|
||||
--yz-header-bg: #1b1f2b;
|
||||
--yz-aside-bg: #1b1f2b;
|
||||
--yz-page-bg: #131720;
|
||||
--yz-card-bg: #1f2431;
|
||||
--yz-border: #2c3344;
|
||||
--yz-text: #e8ecf6;
|
||||
--yz-text-regular: #b9c2d6;
|
||||
--yz-text-muted: #8692ac;
|
||||
--yz-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// body 样式
|
||||
body {
|
||||
// background-color: #f5f7fa;
|
||||
color: #303133;
|
||||
color: var(--yz-text);
|
||||
background-color: var(--yz-page-bg);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<el-menu-item
|
||||
v-if="!item.children || item.children.length === 0"
|
||||
:index="item.path || String(item.id)"
|
||||
:class="{ 'is-locked': item.locked }"
|
||||
>
|
||||
<MenuIcon :icon="item.icon" />
|
||||
<template #title>
|
||||
<span>{{ item.title }}</span>
|
||||
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu
|
||||
v-else
|
||||
:index="item.path || String(item.id)"
|
||||
teleported
|
||||
:class="{ 'is-locked': item.locked }"
|
||||
>
|
||||
<template #title>
|
||||
<MenuIcon :icon="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||
</template>
|
||||
<AsideMenuItem
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
:item="child"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MenuIcon from "@/components/MenuIcon.vue";
|
||||
|
||||
defineOptions({ name: "AsideMenuItem" });
|
||||
|
||||
defineProps({
|
||||
item: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
+1077
-154
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<i v-if="isFontAwesome" :class="icon" class="menu-icon"></i>
|
||||
<el-icon v-else class="menu-icon">
|
||||
<component :is="elementIconName" />
|
||||
</el-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const isFontAwesome = computed(() => /fa-|(^|\s)fa[srlb]?\s/.test(props.icon || ""));
|
||||
|
||||
// main.js 已把所有 Element Plus 图标全局注册;数据库里存的是图标组件名,
|
||||
// 未能识别时统一回退到 Grid,避免渲染出空白图标。
|
||||
const elementIconName = computed(() => {
|
||||
const name = props.icon || "Grid";
|
||||
const components = instance?.appContext?.components || {};
|
||||
return components[name] ? name : "Grid";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,7 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { User, Calendar } from "@element-plus/icons-vue";
|
||||
import { readSiteReminder } from "@/api/sitereminder";
|
||||
import { formatTime } from "@/utils/datetime";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
@@ -62,12 +63,6 @@ const getSenderName = (item: any) => {
|
||||
return item.sender_type;
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: any) => {
|
||||
if (!timeStr) return "-";
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
watch(() => props.modelValue, async (val) => {
|
||||
if (val && props.reminder && !props.viewOnly && props.reminder.is_read === 0) {
|
||||
loading.value = true;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<!-- 当前企业 + 切换:企业名右侧带箭头,点击弹出该账号可进入的企业列表 -->
|
||||
<el-dropdown
|
||||
v-if="companyName"
|
||||
trigger="click"
|
||||
class="tenant-switcher"
|
||||
popper-class="tenant-switcher-popper"
|
||||
@command="handleSwitch"
|
||||
>
|
||||
<span class="tenant-chip" :class="[variantClass, 'is-switchable']" :title="`当前企业:${companyName},点击切换`">
|
||||
<el-icon v-if="iconVisible" class="tenant-chip-icon"><OfficeBuilding /></el-icon>
|
||||
<span class="tenant-chip-text">{{ companyName }}</span>
|
||||
<el-icon class="tenant-chip-caret"><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="item in tenantOptions"
|
||||
:key="item.tid"
|
||||
:command="item.tid"
|
||||
:disabled="isCurrent(item.tid)"
|
||||
>
|
||||
<span class="tenant-option">
|
||||
<span class="tenant-option-name">{{ item.tenant_name }}</span>
|
||||
<el-icon v-if="isCurrent(item.tid)" class="tenant-option-check"><Check /></el-icon>
|
||||
</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="tenantOptions.length === 1" disabled>
|
||||
<span class="tenant-option-tip">当前账号未关联其他企业</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="!tenantOptions.length" :command="RELOAD_COMMAND" :disabled="loading">
|
||||
<span class="tenant-option-tip">
|
||||
{{ loading ? "企业列表加载中…" : "未获取到企业列表,点击重试" }}
|
||||
</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ArrowDown, Check, OfficeBuilding } from "@element-plus/icons-vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { fetchUserInfo, resolveTenantName, switchTenant } from "@/utils/authClient";
|
||||
|
||||
/**
|
||||
* 当前企业名 + 多企业切换。
|
||||
*
|
||||
* variant:
|
||||
* chip —— 浅色标签样式(顶部栏使用)
|
||||
* plain —— 无底色 + 楼宇图标(home 门户顶部使用)
|
||||
*
|
||||
* 只要拿到企业列表(≥1 家)就渲染「企业名 + 箭头」的可点击下拉,
|
||||
* 只有一家时列表内给出说明;列表为空时退化为纯文本企业名。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{ variant?: "chip" | "plain" }>(), {
|
||||
variant: "chip",
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 下拉里「重新拉取企业列表」的命令值
|
||||
const RELOAD_COMMAND = "__reload__";
|
||||
|
||||
const tenantOptions = ref<Array<{ tid: number | string; tenant_name: string }>>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const companyName = computed(() => authStore.user?.tenant_name || "");
|
||||
const currentTid = computed(() => authStore.user?.tid || "");
|
||||
const variantClass = computed(() => (props.variant === "plain" ? "is-plain" : "is-chip"));
|
||||
const iconVisible = computed(() => props.variant === "plain");
|
||||
|
||||
function isCurrent(tid: number | string) {
|
||||
return !!tid && String(tid) === String(currentTid.value);
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
if (!authStore.token) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 用认证中心 userinfo 取「可进入的企业」;同时兜底:本地缓存缺企业名时补上,
|
||||
// 避免顶部企业名为空白(老会话、缓存被清等场景)
|
||||
const info = await fetchUserInfo();
|
||||
const list = Array.isArray(info?.tenants) ? info.tenants : [];
|
||||
tenantOptions.value = list.map((t: any) => ({
|
||||
tid: t.tid,
|
||||
tenant_name: t.tenant_name || `企业${t.tid}`,
|
||||
}));
|
||||
|
||||
const name = resolveTenantName(info);
|
||||
if (name && !companyName.value) {
|
||||
authStore.updateUserInfo({
|
||||
...authStore.user,
|
||||
tid: info?.tid ?? authStore.user?.tid,
|
||||
tenant_name: name,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 拉取失败不影响展示,下拉里会给出「点击重试」
|
||||
tenantOptions.value = [];
|
||||
console.error("加载可进入企业列表失败:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业切换后,上一家的菜单 / 标签页 / 企业信息缓存都必须失效。
|
||||
* 菜单虽然是按 tid 建 key,但旧 key 仍会留在本地,顺手清掉可避免脏数据累积。
|
||||
*/
|
||||
function clearTenantScopedCache() {
|
||||
try {
|
||||
const removeKeys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.startsWith("menu_cache_") || key === "tabs_list" || key === "active_tab")) {
|
||||
removeKeys.push(key);
|
||||
}
|
||||
}
|
||||
removeKeys.forEach((key) => localStorage.removeItem(key));
|
||||
sessionStorage.removeItem("tabs_list");
|
||||
localStorage.removeItem("tenant");
|
||||
sessionStorage.removeItem("tenant");
|
||||
} catch (e) {
|
||||
console.warn("清理企业级缓存失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitch(tid: number | string) {
|
||||
if (tid === RELOAD_COMMAND) {
|
||||
await loadTenants();
|
||||
return;
|
||||
}
|
||||
if (!tid || isCurrent(tid)) return;
|
||||
const target = tenantOptions.value.find((item) => String(item.tid) === String(tid));
|
||||
try {
|
||||
await switchTenant(tid);
|
||||
clearTenantScopedCache();
|
||||
ElMessage.success(`已切换到「${target?.tenant_name || "新企业"}」`);
|
||||
// 切企业后必须离开当前页:当前页可能属于上一家才开通的模块,
|
||||
// 停留在原地会让新企业未开通的功能继续可用。回到首页(门户)锁死入口,
|
||||
// 菜单/权限/套餐再按新企业整页初始化。
|
||||
// 用 replaceState 改 hash(不触发路由跳转),避免用旧企业状态多渲染一次
|
||||
setTimeout(() => {
|
||||
window.history.replaceState({}, document.title, `${window.location.pathname}#/home`);
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "切换企业失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTenants);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 220px;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-switchable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-8);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tenant-chip-caret {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tenant-chip-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部栏:与右侧圆形操作按钮等高对齐
|
||||
.tenant-chip.is-chip {
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
// home 门户顶部:无底色,图标 + 常规文字色(跟随主题变量自动适配暗色)
|
||||
.tenant-chip.is-plain {
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
max-width: 260px;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
&.is-switchable:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 下拉弹层由 el-dropdown 挂到 body,scoped 样式够不到,故用 popper 类名前缀限定 -->
|
||||
<style lang="less">
|
||||
.tenant-switcher-popper {
|
||||
.tenant-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 150px;
|
||||
|
||||
.tenant-option-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tenant-option-check {
|
||||
flex: 0 0 auto;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-option-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<div class="flow-canvas">
|
||||
<!-- 左侧节点面板 -->
|
||||
<div class="palette">
|
||||
<div class="palette-title">节点面板</div>
|
||||
<div class="palette-list">
|
||||
<div
|
||||
v-for="p in palette"
|
||||
:key="p.type"
|
||||
class="palette-item"
|
||||
:class="'pal-' + p.cls"
|
||||
draggable="true"
|
||||
:title="'点击或拖拽添加「' + p.label + '」'"
|
||||
@dragstart="onDragStart($event, p)"
|
||||
@dragend="onDragEnd"
|
||||
@click="onPaletteAdd(p)"
|
||||
>
|
||||
<span class="pal-shape-wrap">
|
||||
<span class="pal-shape" :class="'shape-' + p.cls"></span>
|
||||
</span>
|
||||
<div class="pal-info">
|
||||
<div class="pal-label">{{ p.label }}</div>
|
||||
<div class="pal-desc">{{ p.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="palette-tip">
|
||||
点击或拖拽即可添加节点;<br />从右侧小圆点拖到目标节点连线;<br />双击节点配置 · 点击连线设条件。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 画布 -->
|
||||
<div ref="canvasWrap" class="canvas-wrap" @drop="onDrop" @dragover.prevent @dragenter.prevent>
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:default-viewport="{ zoom: 1 }"
|
||||
:min-zoom="0.4"
|
||||
:max-zoom="2"
|
||||
@node-double-click="onNodeDblClick"
|
||||
@edge-click="onEdgeClick"
|
||||
>
|
||||
<template #node-flow="nodeProps">
|
||||
<!-- 判断节点:菱形 -->
|
||||
<div v-if="nodeProps.data.cls === 'decision'" class="fn-diamond">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<div class="fn-diamond-shape"></div>
|
||||
<div class="fn-diamond-label">{{ nodeProps.data.name || '判断' }}</div>
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<!-- 开始 / 结束:胶囊 -->
|
||||
<div
|
||||
v-else-if="nodeProps.data.cls === 'start' || nodeProps.data.cls === 'end'"
|
||||
class="fn-pill"
|
||||
:class="'fn-pill-' + nodeProps.data.cls"
|
||||
>
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<el-icon class="fn-pill-icon"><component :is="nodeProps.data.cls === 'start' ? VideoPlay : SwitchButton" /></el-icon>
|
||||
<span>{{ nodeProps.data.name || (nodeProps.data.cls === 'start' ? '开始' : '结束') }}</span>
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<!-- 通知节点:圆形 -->
|
||||
<div v-else-if="nodeProps.data.cls === 'notify'" class="fn-circle fn-circle-notify">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<el-icon class="fn-circle-icon"><Bell /></el-icon>
|
||||
<div class="fn-circle-label">{{ nodeProps.data.name || '通知' }}</div>
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
<!-- 审批节点:矩形卡片 -->
|
||||
<div v-else class="fn" :class="'fn-' + nodeProps.data.cls">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
|
||||
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
|
||||
<div class="fn-sub">{{ nodeProps.data.summary }}</div>
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
<Background />
|
||||
<Controls />
|
||||
</VueFlow>
|
||||
</div>
|
||||
|
||||
<!-- 节点配置对话框 -->
|
||||
<el-dialog v-model="nodeDialog" :title="nodeDialogTitle" width="560px" append-to-body>
|
||||
<el-form :model="nodeForm" label-width="96px">
|
||||
<el-form-item label="节点名称" required>
|
||||
<el-input v-model="nodeForm.name" placeholder="如:部门主管审批" />
|
||||
</el-form-item>
|
||||
<template v-if="nodeForm.nodeType === 2">
|
||||
<el-form-item label="审批人类型">
|
||||
<el-radio-group v-model="nodeForm.approverType" @change="onApproverTypeChange">
|
||||
<el-radio v-for="t in approverTypes" :key="t.value" :value="t.value">{{ t.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentApproverMeta && currentApproverMeta.needsSelection" :label="currentApproverMeta.selectLabel" required>
|
||||
<el-select v-model="nodeForm.approverIds" multiple filterable style="width: 100%" :placeholder="'选择' + currentApproverMeta.selectLabel + '(可多选)'">
|
||||
<el-option v-for="opt in currentPool" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label=" ">
|
||||
<span class="hint">该类型运行期自动定位审批人,无需指定对象</span>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="currentApproverMeta && currentApproverMeta.needsSelection && nodeForm.approverIds && nodeForm.approverIds.length > 1"
|
||||
label="多人方式"
|
||||
>
|
||||
<el-radio-group v-model="nodeForm.multiMode">
|
||||
<el-radio :value="1">
|
||||
或签
|
||||
<el-tooltip placement="top" effect="dark">
|
||||
<template #content>
|
||||
<div class="mode-tip">
|
||||
<div class="mode-tip-title">或签 (OR)</div>
|
||||
<div>所选审批人任一人通过/驳回,即视为本节点完成;</div>
|
||||
<div>其余待办任务将自动作废并推进至下一节点。</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</el-radio>
|
||||
<el-radio :value="2">
|
||||
会签
|
||||
<el-tooltip placement="top" effect="dark">
|
||||
<template #content>
|
||||
<div class="mode-tip">
|
||||
<div class="mode-tip-title">会签 (AND)</div>
|
||||
<div>所选审批人须全部通过,本节点才算完成;</div>
|
||||
<div>任一人驳回则本节点立即驳回,流程回退。</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许退回">
|
||||
<el-switch v-model="nodeForm.allowReturn" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="超时(小时)">
|
||||
<el-input-number v-model="nodeForm.timeoutHours" :min="1" :max="720" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
|
||||
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||||
</el-form-item>
|
||||
<template v-if="nodeForm.nodeType === 5">
|
||||
<el-form-item label="通知类型" required>
|
||||
<el-select
|
||||
v-model="nodeForm.notifyTypes"
|
||||
multiple
|
||||
collapse-tags
|
||||
style="width: 100%"
|
||||
placeholder="选择通知类型(可多选)"
|
||||
@change="onNotifyTypesChange"
|
||||
>
|
||||
<el-option v-for="t in approverTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-for="t in selectedNotifyMetas"
|
||||
:key="t.value"
|
||||
:label="t.selectLabel || t.label"
|
||||
required
|
||||
>
|
||||
<el-select v-model="nodeForm.notifyIdsMap[t.value]" multiple filterable style="width: 100%" :placeholder="'选择' + (t.selectLabel || t.label) + '(可多选)'">
|
||||
<el-option v-for="opt in poolOf(t.poolKey)" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!selectedNotifyMetas.length" label=" ">
|
||||
<span class="hint">所选类型运行期自动定位通知对象,无需指定</span>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<span class="hint">通知节点不阻塞流程,流程自动继续往下走。可同时选择多种类型(如「发起人本人」+「指定用户」)。</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="node-dialog-footer">
|
||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
||||
<span class="footer-spacer"></span>
|
||||
<el-button @click="nodeDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmNode">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 连线条件对话框 -->
|
||||
<el-dialog v-model="edgeDialog" title="设置分支条件" width="460px" append-to-body>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="条件表达式">
|
||||
<el-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<span class="hint">支持 > >= < <= == != 与 && / ||。条件仅在“判断节点”的出边上生效。</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" plain @click="removeCurrentEdge">删除连线</el-button>
|
||||
<span style="flex: 1"></span>
|
||||
<el-button @click="edgeDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmEdge">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { VideoPlay, User, Share, SwitchButton, QuestionFilled, Bell } from '@element-plus/icons-vue'
|
||||
import { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/controls/dist/style.css'
|
||||
|
||||
const props = defineProps({
|
||||
// { nodes: [payloadNode], edges: [payloadEdge] }
|
||||
modelValue: { type: Object, default: () => ({ nodes: [], edges: [] }) },
|
||||
// 审批人类型:[{ value, label, needsSelection, selectLabel, poolKey }]
|
||||
approverTypes: { type: Array, default: () => [] },
|
||||
// 候选池:{ [poolKey]: [{ id, name }] }
|
||||
pools: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const nodes = ref<any[]>([])
|
||||
const edges = ref<any[]>([])
|
||||
const canvasWrap = ref<HTMLElement | null>(null)
|
||||
const { screenToFlowCoordinate, onConnect } = useVueFlow()
|
||||
|
||||
let keySeq = 1
|
||||
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
|
||||
|
||||
const NODE_TYPE = { START: 1, APPROVAL: 2, END: 3, DECISION: 4, NOTIFY: 5 }
|
||||
|
||||
const palette = [
|
||||
{ type: NODE_TYPE.START, cls: 'start', label: '开始', desc: '流程唯一入口', icon: VideoPlay },
|
||||
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', desc: '指定审批人处理', icon: User },
|
||||
{ type: NODE_TYPE.DECISION, cls: 'decision', label: '判断节点', desc: '按条件分支路由', icon: Share },
|
||||
{ type: NODE_TYPE.NOTIFY, cls: 'notify', label: '通知节点', desc: '通知不阻塞流程', icon: Bell },
|
||||
{ type: NODE_TYPE.END, cls: 'end', label: '结束', desc: '流程出口', icon: SwitchButton },
|
||||
]
|
||||
|
||||
const clsOf = (t: number) =>
|
||||
t === NODE_TYPE.START ? 'start' : t === NODE_TYPE.END ? 'end' : t === NODE_TYPE.DECISION ? 'decision' : t === NODE_TYPE.NOTIFY ? 'notify' : 'approval'
|
||||
|
||||
const typeShort = (t: number) =>
|
||||
t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.DECISION ? '判断' : t === NODE_TYPE.NOTIFY ? '通知' : '审批'
|
||||
|
||||
const approverTypeLabel = (t: number) => {
|
||||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||
return m ? m.label : '未知'
|
||||
}
|
||||
|
||||
function approverSummary(node: any) {
|
||||
if (node.nodeType === NODE_TYPE.APPROVAL) {
|
||||
const meta = (props.approverTypes as any[]).find((x) => x.value === node.approverType)
|
||||
if (!meta || !meta.needsSelection) return approverTypeLabel(node.approverType)
|
||||
const pool = (props.pools as any)[meta.poolKey] || []
|
||||
const names = (node.approverIds || []).map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
|
||||
const mode = node.multiMode === 2 ? '会签' : '或签'
|
||||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
||||
}
|
||||
if (node.nodeType === NODE_TYPE.DECISION) return '按分支条件路由'
|
||||
if (node.nodeType === NODE_TYPE.NOTIFY) {
|
||||
const types: number[] = node.notifyTypes?.length ? node.notifyTypes : [node.approverType]
|
||||
const parts = types.map((t) => {
|
||||
const meta = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||
if (!meta) return '未知类型'
|
||||
if (!meta.needsSelection) return meta.label
|
||||
const pool = (props.pools as any)[meta.poolKey] || []
|
||||
const ids = (node.notifyIdsMap || {})[t] || []
|
||||
const names = ids.map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
|
||||
return `${meta.label}:${names.length ? names.join('、') : '未选'}`
|
||||
})
|
||||
return '通知 ' + parts.join(' + ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 由 payload 节点/边构建画布内部结构
|
||||
function buildGraph(payload: any) {
|
||||
const pNodes = payload?.nodes || []
|
||||
const pEdges = payload?.edges || []
|
||||
const ns = pNodes.map((n: any) => ({
|
||||
id: n.node_key || nextKey(),
|
||||
type: 'flow',
|
||||
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||
data: makeNodeData(n),
|
||||
}))
|
||||
const es = pEdges
|
||||
.filter((e: any) => e.from && e.to)
|
||||
.map((e: any) => ({
|
||||
id: `e_${e.from}_${e.to}`,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
label: e.condition || '',
|
||||
data: { condition: e.condition || '' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
animated: !!e.condition,
|
||||
}))
|
||||
return { ns, es }
|
||||
}
|
||||
|
||||
function makeNodeData(n: any) {
|
||||
const nodeType = n.node_type || n.nodeType || NODE_TYPE.APPROVAL
|
||||
const data: any = {
|
||||
nodeType,
|
||||
cls: clsOf(nodeType),
|
||||
name: n.node_name || n.name || '',
|
||||
approverType: n.approver_type ?? n.approverType ?? 1,
|
||||
approverIds: n.approver_ids ?? n.approverIds ?? [],
|
||||
multiMode: n.multi_mode ?? n.multiMode ?? 1,
|
||||
allowReturn: n.allow_return ?? n.allowReturn ?? 0,
|
||||
timeoutHours: n.timeout_hours ?? n.timeoutHours ?? 48,
|
||||
}
|
||||
if (nodeType === NODE_TYPE.NOTIFY) {
|
||||
applyNotifyData(data, n)
|
||||
}
|
||||
data.summary = approverSummary(data)
|
||||
return data
|
||||
}
|
||||
|
||||
// 通知节点:解析多类型存储 notify_targets: ["1:52","5:0"];兼容旧单类型格式
|
||||
//(纯数字 approver_ids + approver_type),并统一归一化为 notifyTargets 字符串数组。
|
||||
function applyNotifyData(data: any, n: any) {
|
||||
const targets: string[] = n.notify_targets || n.notifyTargets || []
|
||||
if (targets.length) {
|
||||
const types: number[] = []
|
||||
const map: Record<number, number[]> = {}
|
||||
targets.forEach((s) => {
|
||||
const [tStr, idStr] = String(s).split(':')
|
||||
const t = Number(tStr)
|
||||
if (!t) return
|
||||
if (!map[t]) {
|
||||
map[t] = []
|
||||
types.push(t)
|
||||
}
|
||||
const id = Number(idStr)
|
||||
if (id > 0) map[t].push(id)
|
||||
})
|
||||
data.notifyTargets = targets.map(String)
|
||||
data.notifyTypes = types
|
||||
data.notifyIdsMap = map
|
||||
data.approverType = types[0] || data.approverType
|
||||
} else {
|
||||
const t = data.approverType
|
||||
const ids: number[] = [...(data.approverIds || [])]
|
||||
data.notifyTypes = [t]
|
||||
data.notifyIdsMap = ids.length ? { [t]: ids } : {}
|
||||
data.notifyTargets = ids.length ? ids.map((id: number) => `${t}:${id}`) : [`${t}:0`]
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 / 外部切换流程时重建
|
||||
function reload() {
|
||||
const { ns, es } = buildGraph(props.modelValue || {})
|
||||
// 避免坐标全 0 重叠:无坐标时自动铺开
|
||||
let autoX = 60
|
||||
let autoY = 60
|
||||
ns.forEach((n: any) => {
|
||||
if (!n.position.x && !n.position.y) {
|
||||
n.position = { x: autoX, y: autoY }
|
||||
autoY += 140
|
||||
}
|
||||
})
|
||||
nodes.value = ns
|
||||
edges.value = es
|
||||
void autoX
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, reload, { deep: false, immediate: true })
|
||||
|
||||
// ==================== 拖拽添加 ====================
|
||||
let dragType = 0
|
||||
let dragging = false
|
||||
function onDragStart(_evt: DragEvent, p: any) {
|
||||
dragType = p.type
|
||||
dragging = true
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
// 延迟重置,避免拖拽后紧接的 click 事件误新增
|
||||
setTimeout(() => { dragging = false; dragType = 0 }, 0)
|
||||
}
|
||||
|
||||
function onDrop(evt: DragEvent) {
|
||||
if (!dragType) return
|
||||
const position = screenToFlowCoordinate({ x: evt.clientX, y: evt.clientY })
|
||||
const data = makeNodeData({ node_type: dragType, node_name: defaultName(dragType) })
|
||||
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
|
||||
dragType = 0
|
||||
}
|
||||
|
||||
// 单击 palette 项:自动在画布中心附近新增节点(小量抖动避免重叠)
|
||||
function onPaletteAdd(p: any) {
|
||||
if (dragging) return
|
||||
if (!canvasWrap.value) return
|
||||
const rect = canvasWrap.value.getBoundingClientRect()
|
||||
const base = screenToFlowCoordinate({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
})
|
||||
const jitter = (nodes.value.length % 6) * 26
|
||||
const position = { x: base.x - 80 + jitter, y: base.y - 40 + jitter }
|
||||
const data = makeNodeData({ node_type: p.type, node_name: defaultName(p.type) })
|
||||
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
|
||||
}
|
||||
|
||||
function defaultName(t: number) {
|
||||
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.DECISION ? '判断节点' : t === NODE_TYPE.NOTIFY ? '通知节点' : '审批节点'
|
||||
}
|
||||
|
||||
// ==================== 连线 ====================
|
||||
onConnect((params: any) => {
|
||||
const exists = edges.value.some((e) => e.source === params.source && e.target === params.target)
|
||||
if (exists) return
|
||||
edges.value.push({
|
||||
id: `e_${params.source}_${params.target}`,
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
label: '',
|
||||
data: { condition: '' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
animated: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== 节点配置 ====================
|
||||
const nodeDialog = ref(false)
|
||||
const editingNodeId = ref('')
|
||||
const nodeForm = reactive<any>({
|
||||
name: '',
|
||||
nodeType: NODE_TYPE.APPROVAL,
|
||||
approverType: 1,
|
||||
approverIds: [],
|
||||
multiMode: 1,
|
||||
allowReturn: 0,
|
||||
timeoutHours: 48,
|
||||
// 通知节点多类型:选中的类型 + 各类型对应的对象 ID
|
||||
notifyTypes: [] as number[],
|
||||
notifyIdsMap: {} as Record<number, number[]>,
|
||||
})
|
||||
|
||||
const nodeDialogTitle = computed(() => {
|
||||
if (nodeForm.nodeType === NODE_TYPE.DECISION) return '配置判断节点'
|
||||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) return '配置通知节点'
|
||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
|
||||
return '配置节点'
|
||||
})
|
||||
|
||||
const currentApproverMeta = computed(() =>
|
||||
(props.approverTypes as any[]).find((x) => x.value === nodeForm.approverType) || null
|
||||
)
|
||||
const currentPool = computed(() => {
|
||||
const meta = currentApproverMeta.value
|
||||
return meta ? (props.pools as any)[meta.poolKey] || [] : []
|
||||
})
|
||||
|
||||
const poolOf = (poolKey: string) => (props.pools as any)[poolKey] || []
|
||||
|
||||
// 通知节点:已选类型中需要手动指定对象的元信息
|
||||
const selectedNotifyMetas = computed(() =>
|
||||
(props.approverTypes as any[]).filter(
|
||||
(t) => (nodeForm.notifyTypes || []).includes(t.value) && t.needsSelection
|
||||
)
|
||||
)
|
||||
|
||||
function onNotifyTypesChange(vals: number[]) {
|
||||
const keep = new Set<number>(vals)
|
||||
Object.keys(nodeForm.notifyIdsMap).forEach((k) => {
|
||||
if (!keep.has(Number(k))) delete nodeForm.notifyIdsMap[k]
|
||||
})
|
||||
}
|
||||
|
||||
function onNodeDblClick(evt: any) {
|
||||
const node = evt.node
|
||||
if (!node) return
|
||||
editingNodeId.value = node.id
|
||||
const d = node.data
|
||||
const isNotify = d.nodeType === NODE_TYPE.NOTIFY
|
||||
Object.assign(nodeForm, {
|
||||
name: d.name,
|
||||
nodeType: d.nodeType,
|
||||
approverType: d.approverType,
|
||||
approverIds: [...(d.approverIds || [])],
|
||||
multiMode: d.multiMode,
|
||||
allowReturn: d.allowReturn,
|
||||
timeoutHours: d.timeoutHours,
|
||||
notifyTypes: isNotify ? [...(d.notifyTypes || [d.approverType])] : [],
|
||||
notifyIdsMap: isNotify ? JSON.parse(JSON.stringify(d.notifyIdsMap || {})) : {},
|
||||
})
|
||||
nodeDialog.value = true
|
||||
}
|
||||
|
||||
function onApproverTypeChange() {
|
||||
nodeForm.approverIds = []
|
||||
}
|
||||
|
||||
function confirmNode() {
|
||||
const node = nodes.value.find((n) => n.id === editingNodeId.value)
|
||||
if (!node) {
|
||||
nodeDialog.value = false
|
||||
return
|
||||
}
|
||||
if (!String(nodeForm.name).trim()) {
|
||||
ElMessage.warning('请填写节点名称')
|
||||
return
|
||||
}
|
||||
// 通知节点:多类型 + 各类型对象,归一化为 "类型:ID" 列表
|
||||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) {
|
||||
if (!nodeForm.notifyTypes.length) {
|
||||
ElMessage.warning('请选择通知类型')
|
||||
return
|
||||
}
|
||||
for (const t of nodeForm.notifyTypes) {
|
||||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||
if (m?.needsSelection && !(nodeForm.notifyIdsMap[t] || []).length) {
|
||||
ElMessage.warning(`请选择「${m.label}」的通知对象`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const targets: string[] = []
|
||||
nodeForm.notifyTypes.forEach((t: number) => {
|
||||
const ids = nodeForm.notifyIdsMap[t] || []
|
||||
if (ids.length) {
|
||||
ids.forEach((id: number) => targets.push(`${t}:${id}`))
|
||||
} else {
|
||||
targets.push(`${t}:0`)
|
||||
}
|
||||
})
|
||||
node.data = makeNodeData({
|
||||
node_type: NODE_TYPE.NOTIFY,
|
||||
node_name: nodeForm.name,
|
||||
notify_targets: targets,
|
||||
approver_type: nodeForm.notifyTypes[0],
|
||||
approver_ids: [],
|
||||
multi_mode: nodeForm.multiMode,
|
||||
allow_return: nodeForm.allowReturn,
|
||||
timeout_hours: nodeForm.timeoutHours,
|
||||
})
|
||||
nodeDialog.value = false
|
||||
return
|
||||
}
|
||||
const meta = currentApproverMeta.value
|
||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||
ElMessage.warning('请选择审批人')
|
||||
return
|
||||
}
|
||||
node.data = makeNodeData({
|
||||
node_type: nodeForm.nodeType,
|
||||
node_name: nodeForm.name,
|
||||
approver_type: nodeForm.approverType,
|
||||
approver_ids: nodeForm.approverIds,
|
||||
multi_mode: nodeForm.multiMode,
|
||||
allow_return: nodeForm.allowReturn,
|
||||
timeout_hours: nodeForm.timeoutHours,
|
||||
})
|
||||
nodeDialog.value = false
|
||||
}
|
||||
|
||||
function removeCurrentNode() {
|
||||
const id = editingNodeId.value
|
||||
if (!id) return
|
||||
nodes.value = nodes.value.filter((n) => n.id !== id)
|
||||
edges.value = edges.value.filter((e) => e.source !== id && e.target !== id)
|
||||
nodeDialog.value = false
|
||||
}
|
||||
|
||||
// ==================== 连线条件 ====================
|
||||
const edgeDialog = ref(false)
|
||||
const editingEdgeId = ref('')
|
||||
const edgeForm = reactive<any>({ condition: '' })
|
||||
|
||||
function onEdgeClick(evt: any) {
|
||||
const edge = evt.edge
|
||||
if (!edge) return
|
||||
editingEdgeId.value = edge.id
|
||||
edgeForm.condition = edge.data?.condition || ''
|
||||
edgeDialog.value = true
|
||||
}
|
||||
|
||||
function confirmEdge() {
|
||||
const edge = edges.value.find((e) => e.id === editingEdgeId.value)
|
||||
if (edge) {
|
||||
edge.data = { condition: edgeForm.condition }
|
||||
edge.label = edgeForm.condition || ''
|
||||
edge.animated = !!edgeForm.condition
|
||||
}
|
||||
edgeDialog.value = false
|
||||
}
|
||||
|
||||
function removeCurrentEdge() {
|
||||
edges.value = edges.value.filter((e) => e.id !== editingEdgeId.value)
|
||||
edgeDialog.value = false
|
||||
}
|
||||
|
||||
// ==================== 对外序列化 ====================
|
||||
function serialize() {
|
||||
const outNodes = nodes.value.map((n: any, i: number) => {
|
||||
const isNotify = n.data.nodeType === NODE_TYPE.NOTIFY
|
||||
return {
|
||||
node_key: n.id,
|
||||
node_name: n.data.name,
|
||||
node_type: n.data.nodeType,
|
||||
approver_type: isNotify
|
||||
? (n.data.notifyTypes?.length ? n.data.notifyTypes[0] : n.data.approverType)
|
||||
: n.data.approverType,
|
||||
approver_ids: isNotify ? [] : n.data.approverIds,
|
||||
notify_targets: isNotify ? n.data.notifyTargets || [] : [],
|
||||
multi_mode: n.data.multiMode,
|
||||
allow_return: n.data.allowReturn,
|
||||
timeout_hours: n.data.timeoutHours,
|
||||
sort: i + 1,
|
||||
pos_x: Math.round(n.position.x),
|
||||
pos_y: Math.round(n.position.y),
|
||||
}
|
||||
})
|
||||
const outEdges = edges.value.map((e: any) => ({
|
||||
from: e.source,
|
||||
to: e.target,
|
||||
condition: e.data?.condition || '',
|
||||
}))
|
||||
return { nodes: outNodes, edges: outEdges }
|
||||
}
|
||||
|
||||
async function fitView() {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
defineExpose({ serialize, fitView })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.flow-canvas {
|
||||
display: flex;
|
||||
height: 560px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 220px;
|
||||
flex: 0 0 220px;
|
||||
padding: 14px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
overflow-y: auto;
|
||||
|
||||
.palette-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
min-height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--el-bg-color);
|
||||
cursor: grab;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 3px 10px rgba(64, 158, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.pal-shape-wrap {
|
||||
flex: 0 0 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pal-shape {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// 开始/结束:胶囊
|
||||
.shape-start,
|
||||
.shape-end {
|
||||
width: 22px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: #67c23a;
|
||||
}
|
||||
.shape-end {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
// 审批节点:矩形
|
||||
.shape-approval {
|
||||
width: 20px;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
// 判断节点:菱形(旋转方块)
|
||||
.shape-decision {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
// 通知节点:圆形
|
||||
.shape-notify {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
background: #9254de;
|
||||
}
|
||||
|
||||
.pal-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pal-label {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pal-desc {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.pal-start { border-left: 3px solid #67c23a; }
|
||||
.pal-approval { border-left: 3px solid #409eff; }
|
||||
.pal-decision { border-left: 3px solid #e6a23c; }
|
||||
.pal-notify { border-left: 3px solid #9254de; }
|
||||
.pal-end { border-left: 3px solid #909399; }
|
||||
|
||||
.palette-tip {
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11.5px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.7;
|
||||
background: rgba(64, 158, 255, 0.05);
|
||||
border: 1px dashed rgba(64, 158, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.fn {
|
||||
min-width: 150px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
font-size: 13px;
|
||||
|
||||
.fn-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fn-title {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fn-sub {
|
||||
margin-top: 2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
|
||||
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
|
||||
.fn-end { border-top: 3px solid #909399; .fn-badge { background: #909399; } }
|
||||
|
||||
// 开始 / 结束:胶囊
|
||||
.fn-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 90px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
.fn-pill-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
.fn-pill-start {
|
||||
border-color: var(--el-color-success);
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
.fn-pill-end {
|
||||
border-color: var(--el-border-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
// 判断节点:菱形
|
||||
.fn-diamond {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.fn-diamond-shape {
|
||||
position: absolute;
|
||||
inset: 18px;
|
||||
transform: rotate(45deg);
|
||||
background: var(--el-color-warning-light-9);
|
||||
border: 2px solid var(--el-color-warning);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.fn-diamond-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-warning);
|
||||
text-align: center;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// 通知节点:圆形
|
||||
.fn-circle {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.fn-circle-notify {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.fn-circle-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fn-circle-label {
|
||||
max-width: 70px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
/* 多人方式问号图标 + 气泡内容:因 el-dialog / el-tooltip 均 append-to-body,需非 scoped 样式 */
|
||||
.el-radio .mode-tip-icon {
|
||||
margin-left: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
vertical-align: -2px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.el-radio .mode-tip-icon:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.el-popper.is-dark .mode-tip {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
max-width: 260px;
|
||||
}
|
||||
.el-popper.is-dark .mode-tip .mode-tip-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #ffd04b;
|
||||
}
|
||||
|
||||
// 节点弹窗底部:删除靠左,取消/确定靠右
|
||||
.node-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useMenuStore } from "@/stores/menu";
|
||||
import { pickDefaultChild, isWorkbenchMenu } from "@/router/menuDefaults";
|
||||
|
||||
/**
|
||||
* 后台框架导航模型。
|
||||
*
|
||||
* 层级约定:
|
||||
* 模块(从门户 /home 进入,如「OA管理」/apps/oa)
|
||||
* └─ 顶级菜单(顶部导航,如「仪表盘」「审批中心」)
|
||||
* └─ 左侧菜单(选中顶级菜单后的内容)
|
||||
*
|
||||
* 顶部导航展示的是当前模块下的顶级菜单,不是门户里的模块卡片。
|
||||
* 选中态直接由当前路由反推,因此标签页切换、刷新、深链接都能自动还原。
|
||||
*/
|
||||
|
||||
const fixedCmsMenu = {
|
||||
id: -200,
|
||||
path: "/apps/cms",
|
||||
title: "内容管理",
|
||||
icon: "Document",
|
||||
sort: -200,
|
||||
order: -200,
|
||||
children: [
|
||||
{
|
||||
id: -2001,
|
||||
path: "/apps/cms/analytics/content",
|
||||
title: "内容统计",
|
||||
icon: "DataAnalysis",
|
||||
sort: 0,
|
||||
order: 0,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -201,
|
||||
path: "/apps/cms/article",
|
||||
title: "文章管理",
|
||||
icon: "Document",
|
||||
sort: 1,
|
||||
order: 1,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -202,
|
||||
path: "/apps/cms/article/type",
|
||||
title: "文章分类",
|
||||
icon: "Folder",
|
||||
sort: 2,
|
||||
order: 2,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -203,
|
||||
path: "/apps/cms/products",
|
||||
title: "产品管理",
|
||||
icon: "Goods",
|
||||
sort: 3,
|
||||
order: 3,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -204,
|
||||
path: "/apps/cms/products/types",
|
||||
title: "产品分类",
|
||||
icon: "Folder",
|
||||
sort: 4,
|
||||
order: 4,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -205,
|
||||
path: "/apps/cms/solutions",
|
||||
title: "解决方案管理",
|
||||
icon: "Promotion",
|
||||
sort: 5,
|
||||
order: 5,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: -206,
|
||||
path: "/apps/cms/solutions/types",
|
||||
title: "解决方案分类",
|
||||
icon: "Folder",
|
||||
sort: 6,
|
||||
order: 6,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function processMenus(menus) {
|
||||
return (menus || [])
|
||||
.filter((menu) => {
|
||||
// is_visible 只控制侧栏/顶部是否展示,不影响动态路由注册
|
||||
if (menu.is_visible !== undefined && Number(menu.is_visible) === 0) {
|
||||
return false;
|
||||
}
|
||||
if (menu.path && String(menu.path).trim() !== "") return true;
|
||||
return !!(menu.children && menu.children.length > 0);
|
||||
})
|
||||
.map((menu) => {
|
||||
const sort = Number(menu.sort) || 0;
|
||||
return {
|
||||
id: menu.id,
|
||||
pid: menu.pid || 0,
|
||||
path: menu.path,
|
||||
icon: menu.icon || "Grid",
|
||||
title: menu.title,
|
||||
component_path: menu.component_path || "",
|
||||
type: menu.type,
|
||||
locked: !!menu.locked,
|
||||
sort,
|
||||
order: sort,
|
||||
children: menu.children ? processMenus(menu.children) : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeFixedCmsMenu(menus) {
|
||||
return menus.some((menu) => menu.path === fixedCmsMenu.path)
|
||||
? menus
|
||||
: [fixedCmsMenu, ...menus];
|
||||
}
|
||||
|
||||
const ERP_ROOT = "/apps/erp";
|
||||
const ERP_WORKBENCH_DIR = "/apps/erp/workbench";
|
||||
const ERP_DASHBOARD_PAGE = "/apps/erp/dashboard";
|
||||
const ERP_APPROVAL_PAGE = "/apps/erp/workbench/approval";
|
||||
|
||||
/**
|
||||
* 进销存「工作台」统一为目录,目录内固定挂「工作台 / 审批列表」两个页面。
|
||||
*
|
||||
* 菜单由数据库下发,erp_menus.sql 未重跑时工作台仍是模块根下的一个叶子页面,
|
||||
* 这里在导航模型里就地补齐目录结构,保证前端展示与 SQL 的目标结构一致;
|
||||
* 数据库已是正确的目录结构时只做幂等补充,不会重复插入。
|
||||
*/
|
||||
function normalizeErpWorkbench(menus) {
|
||||
return (menus || []).map((menu) => {
|
||||
if (menu.path !== ERP_ROOT) {
|
||||
return menu.children?.length
|
||||
? { ...menu, children: normalizeErpWorkbench(menu.children) }
|
||||
: menu;
|
||||
}
|
||||
|
||||
const children = [...(menu.children || [])];
|
||||
const dir = children.find((m) => m.path === ERP_WORKBENCH_DIR && m.children?.length);
|
||||
const dashboard = children.find((m) => m.path === ERP_DASHBOARD_PAGE);
|
||||
const pages = dir ? [...(dir.children || [])] : [];
|
||||
|
||||
// 不能用 isWorkbenchMenu 判断:审批列表路径里也带 workbench,会被误判成工作台页
|
||||
const hasDashboard = pages.some(
|
||||
(m) => m.path === ERP_DASHBOARD_PAGE || String(m.title || "") === "工作台"
|
||||
);
|
||||
if (!hasDashboard) {
|
||||
pages.push(
|
||||
dashboard
|
||||
? { ...dashboard, title: "工作台", sort: 1, order: 1 }
|
||||
: {
|
||||
id: -3001,
|
||||
path: ERP_DASHBOARD_PAGE,
|
||||
title: "工作台",
|
||||
icon: "DataBoard",
|
||||
component_path: "/apps/erp/dashboard/index.vue",
|
||||
type: 2,
|
||||
sort: 1,
|
||||
order: 1,
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!pages.some((m) => m.path === ERP_APPROVAL_PAGE)) {
|
||||
pages.push({
|
||||
id: -3002,
|
||||
path: ERP_APPROVAL_PAGE,
|
||||
title: "审批列表",
|
||||
icon: "Tickets",
|
||||
component_path: "/apps/erp/workbench/approvalList.vue",
|
||||
type: 2,
|
||||
sort: 2,
|
||||
order: 2,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
const workbenchDir = dir
|
||||
? { ...dir, type: 1, component_path: "", children: pages }
|
||||
: {
|
||||
id: -300,
|
||||
pid: menu.id,
|
||||
path: ERP_WORKBENCH_DIR,
|
||||
title: "工作台",
|
||||
icon: "DataBoard",
|
||||
component_path: "",
|
||||
type: 1,
|
||||
sort: 1,
|
||||
order: 1,
|
||||
children: pages,
|
||||
};
|
||||
|
||||
// 原工作台叶子(或空的工作台目录)已被收进新目录,避免同一功能出现两份
|
||||
const rest = children.filter(
|
||||
(m) => m.path !== ERP_WORKBENCH_DIR && m.path !== ERP_DASHBOARD_PAGE
|
||||
);
|
||||
return { ...menu, children: [workbenchDir, ...rest] };
|
||||
});
|
||||
}
|
||||
|
||||
function sortMenusRecursively(menus) {
|
||||
(menus || []).forEach((menu) => {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children.sort((a, b) => {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||
});
|
||||
sortMenusRecursively(menu.children);
|
||||
}
|
||||
});
|
||||
return menus;
|
||||
}
|
||||
|
||||
function findMenuItem(menus, targetIndex) {
|
||||
for (const menu of menus || []) {
|
||||
if (menu.path === targetIndex) return menu;
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const found = findMenuItem(menu.children, targetIndex);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInSubtree(base, currentPath) {
|
||||
if (!base) return false;
|
||||
const normalized = base.replace(/\/+$/, "");
|
||||
return currentPath === normalized || currentPath.startsWith(`${normalized}/`);
|
||||
}
|
||||
|
||||
function findParentModule(menus, currentPath) {
|
||||
for (const menu of menus) {
|
||||
if (!menu.path || menu.path === "/home") {
|
||||
const found = findParentModule(menu.children || [], currentPath);
|
||||
if (found) return found;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPathInSubtree(menu.path, currentPath)) return menu;
|
||||
|
||||
for (const child of menu.children || []) {
|
||||
if (isPathInSubtree(child.path, currentPath)) return menu;
|
||||
for (const grandchild of child.children || []) {
|
||||
if (isPathInSubtree(grandchild.path, currentPath)) return menu;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 在当前模块的顶级菜单里,找出路径最具体覆盖当前路由的那一项 */
|
||||
function findActiveTopMenu(topMenus, currentPath) {
|
||||
let best = null;
|
||||
for (const top of topMenus) {
|
||||
const stack = [top];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (node.path && isPathInSubtree(node.path, currentPath)) {
|
||||
const length = node.path.length;
|
||||
if (!best || length > best.length) {
|
||||
best = { menu: top, length };
|
||||
}
|
||||
}
|
||||
if (node.children && node.children.length) {
|
||||
stack.push(...node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return best?.menu || null;
|
||||
}
|
||||
|
||||
/** 点击顶级菜单时落到具体页面,避免多出一个「目录」标签页 */
|
||||
function resolveLandingMenu(menu) {
|
||||
if (!menu) return null;
|
||||
if (menu.locked) return menu.path ? menu : null;
|
||||
|
||||
let current = menu;
|
||||
const visited = new Set();
|
||||
while (current.children && current.children.length > 0 && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
// 目录本身有页面时,访问目录路径即可(路由会渲染 index 子路由)
|
||||
if (current.component_path) break;
|
||||
const next = pickDefaultChild(current.children);
|
||||
if (!next) break;
|
||||
current = next;
|
||||
}
|
||||
return current.path ? current : menu.path ? menu : null;
|
||||
}
|
||||
|
||||
function flattenMenus(menus, parentTitle = "") {
|
||||
const result = [];
|
||||
for (const menu of menus || []) {
|
||||
// 只把「可直接打开的页面」放进搜索结果,纯目录交给路由 redirect,
|
||||
// 否则会同时产生「目录」和「落地页」两个标签页
|
||||
const isPage = !menu.children?.length || !!menu.component_path;
|
||||
if (menu.path && menu.path !== "/home" && isPage) {
|
||||
result.push({
|
||||
id: menu.id,
|
||||
path: menu.path,
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
locked: menu.locked,
|
||||
parentTitle: parentTitle || menu.title,
|
||||
});
|
||||
}
|
||||
if (menu.children && menu.children.length) {
|
||||
result.push(...flattenMenus(menu.children, menu.title || parentTitle));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 在当前模块下找到可直接打开的「仪表盘 / 工作台」页面,作为默认标签页 */
|
||||
function findWorkbenchPage(menus) {
|
||||
const stack = [...menus];
|
||||
const visited = new Set();
|
||||
while (stack.length) {
|
||||
const node = stack.shift();
|
||||
if (!node || visited.has(node.id)) continue;
|
||||
visited.add(node.id);
|
||||
if (
|
||||
node.path &&
|
||||
isWorkbenchMenu(node) &&
|
||||
(!node.children?.length || !!node.component_path)
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
if (node.children?.length) stack.push(...node.children);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useAdminNavigation() {
|
||||
const route = useRoute();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const list = computed(() =>
|
||||
sortMenusRecursively(
|
||||
processMenus(normalizeErpWorkbench(mergeFixedCmsMenu(menuStore.menus || [])))
|
||||
),
|
||||
);
|
||||
|
||||
const currentModule = computed(() => {
|
||||
const path = route.path;
|
||||
if (!path || path === "/" || path === "/home") return null;
|
||||
return findParentModule(list.value, path);
|
||||
});
|
||||
|
||||
const topMenus = computed(() => currentModule.value?.children || []);
|
||||
|
||||
const activeTopMenu = computed(() => {
|
||||
const menus = topMenus.value;
|
||||
if (!menus.length) return null;
|
||||
return (
|
||||
findActiveTopMenu(menus, route.path) ||
|
||||
pickDefaultChild(menus) ||
|
||||
menus[0] ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const sideMenus = computed(() => {
|
||||
const active = activeTopMenu.value;
|
||||
if (!active) return [];
|
||||
return active.children && active.children.length ? active.children : [active];
|
||||
});
|
||||
|
||||
const sideTitle = computed(
|
||||
() => activeTopMenu.value?.title || currentModule.value?.title || "功能菜单",
|
||||
);
|
||||
|
||||
const searchableMenus = computed(() => flattenMenus(list.value));
|
||||
|
||||
// 「首页」已移除:进入模块后默认展示该模块的仪表盘 / 工作台
|
||||
const defaultTab = computed(() => {
|
||||
const module = currentModule.value;
|
||||
if (!module) return null;
|
||||
const target =
|
||||
findWorkbenchPage(module.children || []) || resolveLandingMenu(module);
|
||||
return target?.path ? target : null;
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
currentModule,
|
||||
topMenus,
|
||||
activeTopMenu,
|
||||
sideMenus,
|
||||
sideTitle,
|
||||
defaultTab,
|
||||
searchableMenus,
|
||||
findMenuItem,
|
||||
resolveLandingMenu,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { computed } from 'vue'
|
||||
import { useDictStore } from '@/stores/dict'
|
||||
|
||||
/**
|
||||
* 字典 Composable:把「加载 + 取值 + 文案/标签色查询」收敛成一行调用。
|
||||
*
|
||||
* 用法:
|
||||
* const { items, options, label, tagType } = useDictionary('payment_channel')
|
||||
* // 模板:<el-option v-for="o in options" :label="o.label" :value="o.value" />
|
||||
* // <el-tag :type="tagType(row.status)">{{ label(row.status) }}</el-tag>
|
||||
*
|
||||
* @param {string} code 字典编码(库里真实的 dict_code)
|
||||
*/
|
||||
export function useDictionary(code) {
|
||||
const dictStore = useDictStore()
|
||||
// setup 阶段同步触发加载:缓存命中时不发请求,首次会自动拉取并响应式刷新
|
||||
dictStore.ensure(code)
|
||||
|
||||
/** 字典项原始数组(含 item_value / item_label / tag_type 等) */
|
||||
const items = computed(() => dictStore.getDictItemsSync(code))
|
||||
/** el-select / el-option 直接可用的 { label, value } 列表 */
|
||||
const options = computed(() => items.value.map((it) => ({
|
||||
label: it.item_label,
|
||||
value: it.item_value,
|
||||
tag_type: it.tag_type
|
||||
})))
|
||||
|
||||
return {
|
||||
items,
|
||||
options,
|
||||
label: (value, fallback) => dictStore.getDictLabel(code, value, fallback),
|
||||
tagType: (value, fallback) => dictStore.getDictTagType(code, value, fallback),
|
||||
find: (value) => dictStore.getDictItem(code, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只要「值 → 文案」或「值 → 标签色」时的轻量用法。
|
||||
* const channelLabel = useDictLabel('payment_channel')
|
||||
*/
|
||||
export function useDictLabel(code) {
|
||||
const dictStore = useDictStore()
|
||||
dictStore.ensure(code)
|
||||
return (value, fallback) => dictStore.getDictLabel(code, value, fallback)
|
||||
}
|
||||
|
||||
export function useDictTagType(code) {
|
||||
const dictStore = useDictStore()
|
||||
dictStore.ensure(code)
|
||||
return (value, fallback) => dictStore.getDictTagType(code, value, fallback)
|
||||
}
|
||||
|
||||
/** 预加载多个字典(页面 onMounted 里调用,一次批量请求) */
|
||||
export function useDictPreload(codes) {
|
||||
const dictStore = useDictStore()
|
||||
return () => dictStore.preloadDicts(codes)
|
||||
}
|
||||
|
||||
export default useDictionary
|
||||
Vendored
+6
@@ -26,6 +26,12 @@ declare module '@/stores/auth' {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
// 统一认证中心(本地登录已停用)
|
||||
readonly VITE_AUTH_BASE?: string;
|
||||
readonly VITE_AUTH_CLIENT_ID?: string;
|
||||
readonly VITE_AUTH_REDIRECT_URI?: string;
|
||||
readonly VITE_AUTH_POST_LOGOUT_URI?: string;
|
||||
readonly VITE_AUTH_MODE?: string;
|
||||
// 添加其他环境变量...
|
||||
}
|
||||
|
||||
|
||||
+30
-16
@@ -14,10 +14,16 @@ import router from './router'
|
||||
import { loadAndAddDynamicRoutes } from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { redirectToAuthorize, isAuthHandoff } from '@/utils/authClient'
|
||||
// import { initTheme } from './utils/theme'
|
||||
// 导入全局组件
|
||||
import UmoEditor from '@/views/components/UmoEditor.vue';
|
||||
|
||||
// 兼容某些依赖(如 HTML→docx 转换库)对 Node 全局变量 global 的引用
|
||||
if (typeof window !== 'undefined' && !window.global) {
|
||||
window.global = window;
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
// 全局注册 UmoEditor 组件
|
||||
@@ -34,24 +40,32 @@ app.use(VueViewer)
|
||||
// 初始化主题(必须在挂载前执行)
|
||||
// initTheme()
|
||||
|
||||
// 初始化时检查认证状态
|
||||
// 初始化时检查认证状态。
|
||||
// 认证交接页(/auth/callback、/app-login、/mp-login)正在兑换令牌,此时
|
||||
// localStorage 里还是旧 token。启动期请求带旧 token 打到后端会触发 401 自动
|
||||
// 恢复,进而清掉刚换到的新令牌并重新跳回登录页。交接页一律跳过启动期的
|
||||
// 登录态请求与自动跳转,由交接页写完令牌后整页重载。
|
||||
const authStore = useAuthStore()
|
||||
authStore.checkAuth()
|
||||
if (!isAuthHandoff()) {
|
||||
authStore.checkAuth()
|
||||
|
||||
// 如果用户已登录,在应用启动时加载动态路由
|
||||
if (authStore.isLoggedIn) {
|
||||
loadAndAddDynamicRoutes()
|
||||
.catch(err => {
|
||||
console.error('应用启动时加载动态路由失败:', err);
|
||||
})
|
||||
.then(() => {
|
||||
// 检查是否因为 token 无效而导致路由加载失败
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
authStore.clearToken();
|
||||
window.location.href = '#/login';
|
||||
}
|
||||
});
|
||||
// 如果用户已登录,在应用启动时加载动态路由
|
||||
if (authStore.isLoggedIn) {
|
||||
loadAndAddDynamicRoutes()
|
||||
.catch(err => {
|
||||
console.error('应用启动时加载动态路由失败:', err);
|
||||
})
|
||||
.then(() => {
|
||||
// 检查是否因为 token 无效而导致路由加载失败
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// 本地登录已停用:直接回认证中心重新登录(不再跳 #/login)
|
||||
redirectToAuthorize().catch((err) => {
|
||||
console.error('跳转统一认证失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createComponentLoader } from '@/utils/pathResolver';
|
||||
import { pickDefaultChild } from './menuDefaults';
|
||||
|
||||
function computeFullPath(menuPath, parentPath) {
|
||||
if (!menuPath) return parentPath || '';
|
||||
@@ -38,6 +39,9 @@ function resolvePageComponent(menu) {
|
||||
return () => import('@/views/404/404.vue');
|
||||
}
|
||||
|
||||
/** 套餐未开通模块的锁定占位页组件 */
|
||||
const lockedPageComponent = () => import('@/views/locked/index.vue');
|
||||
|
||||
/**
|
||||
* 菜单子节点 -> 嵌套路由(path 相对 layoutAbsPath)
|
||||
*/
|
||||
@@ -56,9 +60,24 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
id: menu.id,
|
||||
code: menu.code || menu.module_code,
|
||||
componentPath: menu.component_path,
|
||||
};
|
||||
|
||||
// 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404)
|
||||
if (menu.locked) {
|
||||
const route = {
|
||||
path: relPath,
|
||||
name: `menu_${menu.id}`,
|
||||
meta,
|
||||
component: lockedPageComponent,
|
||||
};
|
||||
if (hasChildren) {
|
||||
route.children = convertNestedMenuChildren(menu.children, childAbs);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
// 既有自己的页面又有子菜单:套一层 EmptyLayout,避免父页面组件里没有 <router-view> 导致子路由无法渲染
|
||||
if (hasChildren && ownPage) {
|
||||
return {
|
||||
@@ -87,7 +106,7 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
|
||||
component: () => import('@/views/layouts/EmptyLayout.vue'),
|
||||
children: convertNestedMenuChildren(menu.children, childAbs),
|
||||
};
|
||||
const firstChild = menu.children[0];
|
||||
const firstChild = pickDefaultChild(menu.children);
|
||||
if (firstChild && firstChild.path) {
|
||||
const firstAbs = computeFullPath(firstChild.path, childAbs);
|
||||
const firstRel = toRelativeChildPath(childAbs, firstAbs);
|
||||
@@ -125,9 +144,24 @@ export function convertMenusToRoutes(menus, parentPath = '') {
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
id: menu.id,
|
||||
code: menu.code || menu.module_code,
|
||||
componentPath: menu.component_path,
|
||||
};
|
||||
|
||||
// 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404)
|
||||
if (menu.locked) {
|
||||
const route = {
|
||||
path: fullPath || menu.path || '',
|
||||
name: `menu_${menu.id}`,
|
||||
meta,
|
||||
component: lockedPageComponent,
|
||||
};
|
||||
if (hasChildren) {
|
||||
route.children = convertMenusToRoutes(menu.children, fullPath);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
// 顶层:有页面 + 有子菜单 -> EmptyLayout + 默认子路由 + 相对 path 子路由
|
||||
if (hasChildren && ownPage) {
|
||||
return {
|
||||
@@ -160,9 +194,11 @@ export function convertMenusToRoutes(menus, parentPath = '') {
|
||||
} else if (hasChildren) {
|
||||
route.component = () => import('@/views/layouts/EmptyLayout.vue');
|
||||
route.children = convertMenusToRoutes(menu.children, fullPath);
|
||||
const defaultChild = menu.children.find((child) =>
|
||||
child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
|
||||
) || menu.children[0];
|
||||
// CMS 内容统计是内置默认页,保持原行为;其余模块统一优先「工作台」
|
||||
const defaultChild =
|
||||
menu.children.find(
|
||||
(child) => child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
|
||||
) || pickDefaultChild(menu.children);
|
||||
if (defaultChild && defaultChild.path) {
|
||||
const childFullPath = defaultChild.path.startsWith('/')
|
||||
? defaultChild.path
|
||||
|
||||
+457
-300
@@ -1,300 +1,457 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||
|
||||
// 静态子路由:需要在 Main 框架内显示的页面
|
||||
const staticMainChildren = [
|
||||
// 默认首页:访问 / 时直接展示工作台,URL 保持为 /
|
||||
{
|
||||
path: "",
|
||||
name: "Dashboard",
|
||||
component: () => import("@/views/dashboard/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作台" }
|
||||
},
|
||||
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
||||
// 统一结构:index 为列表页,type 为分类页。
|
||||
{
|
||||
path: "/apps/cms/analytics/content",
|
||||
name: "CmsContentAnalytics",
|
||||
component: () => import("@/views/apps/cms/analytics/content/index.vue"),
|
||||
meta: { requiresAuth: true, title: "内容统计", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/article",
|
||||
name: "CmsArticles",
|
||||
component: () => import("@/views/apps/cms/article/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "文章管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/article/type",
|
||||
name: "CmsArticleCategories",
|
||||
component: () => import("@/views/apps/cms/article/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "文章分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/products",
|
||||
name: "CmsProducts",
|
||||
component: () => import("@/views/apps/cms/product/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/products/types",
|
||||
name: "CmsProductCategories",
|
||||
component: () => import("@/views/apps/cms/product/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/solutions",
|
||||
name: "CmsSolutions",
|
||||
component: () => import("@/views/apps/cms/solution/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "解决方案管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/solutions/types",
|
||||
name: "CmsSolutionCategories",
|
||||
component: () => import("@/views/apps/cms/solution/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "解决方案分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
// 兼容旧路径:articles/* -> article/*
|
||||
{
|
||||
path: "/apps/cms/articles",
|
||||
redirect: "/apps/cms/article"
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/articles/category",
|
||||
redirect: "/apps/cms/article/type"
|
||||
},
|
||||
{
|
||||
path: "/user/userProfile",
|
||||
name: "userProfile",
|
||||
component: () => import("@/views/user/userProfile.vue"),
|
||||
meta: { requiresAuth: true, title: "用户中心" }
|
||||
},
|
||||
// 组织架构(组织 / 员工 / 职位)
|
||||
// 进销存(erp)与办公自动化(oa)各有独立页面,但读写同一份组织数据。
|
||||
{
|
||||
path: "/apps/erp/organization",
|
||||
name: "ErpOrganization",
|
||||
component: () => import("@/views/apps/erp/organization/index.vue"),
|
||||
meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/erp/employee",
|
||||
name: "ErpEmployee",
|
||||
component: () => import("@/views/apps/erp/employee/index.vue"),
|
||||
meta: { requiresAuth: true, title: "员工管理", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/erp/position",
|
||||
name: "ErpPosition",
|
||||
component: () => import("@/views/apps/erp/position/index.vue"),
|
||||
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/organization",
|
||||
name: "Organization",
|
||||
component: () => import("@/views/apps/oa/organization/index.vue"),
|
||||
meta: { requiresAuth: true, title: "组织架构", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/employee",
|
||||
name: "OaEmployee",
|
||||
component: () => import("@/views/apps/oa/employee/index.vue"),
|
||||
meta: { requiresAuth: true, title: "人员管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/position",
|
||||
name: "OaPosition",
|
||||
component: () => import("@/views/apps/oa/position/index.vue"),
|
||||
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/tools/passwordStore",
|
||||
name: "BackendPasswordStore",
|
||||
component: () => import("@/views/tools/passwordStore/index.vue"),
|
||||
meta: { requiresAuth: true, title: "密码存储" }
|
||||
},
|
||||
// 兼容拼写错误的路径重定向
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
redirect: "/apps/erp/dashboard"
|
||||
}
|
||||
];
|
||||
|
||||
// 静态路由:登录页独立、home 导航门户独立、404 页面独立
|
||||
const staticRoutes = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/index.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "Register",
|
||||
component: () => import("@/views/login/register.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/forget",
|
||||
name: "ForgetPassword",
|
||||
component: () => import("@/views/login/forget.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
component: () => import("@/views/home/index.vue"),
|
||||
meta: { requiresAuth: true, title: "系统导航", isStandalone: true }
|
||||
},
|
||||
// 兼容路径拼写错误:dashborad -> dashboard
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
redirect: "/apps/erp/dashboard"
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/404/404.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes
|
||||
});
|
||||
|
||||
let dynamicRoutesAdded = false;
|
||||
let dynamicRoutesData = [];
|
||||
let routesLoadingPromise = null;
|
||||
|
||||
export function resetDynamicRoutes() {
|
||||
dynamicRoutesAdded = false;
|
||||
routesLoadingPromise = null;
|
||||
}
|
||||
|
||||
export async function loadAndAddDynamicRoutes() {
|
||||
if (routesLoadingPromise) {
|
||||
return routesLoadingPromise;
|
||||
}
|
||||
|
||||
if (dynamicRoutesAdded) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
routesLoadingPromise = (async () => {
|
||||
try {
|
||||
const { useMenuStore } = await import("@/stores/menu");
|
||||
const menuStore = useMenuStore();
|
||||
const menuData = await menuStore.fetchMenus();
|
||||
|
||||
if (menuData && menuData.length > 0) {
|
||||
addDynamicRoutes(menuData);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
// 即使权限菜单为空,也要注册内置文章中心路由。
|
||||
addDynamicRoutes([]);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载动态路由失败:', error);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return routesLoadingPromise;
|
||||
}
|
||||
|
||||
// 核心修改:移除扁平化,直接使用嵌套菜单生成路由
|
||||
function addDynamicRoutes(menus) {
|
||||
if (!menus?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接转换嵌套菜单为嵌套路由(不再扁平化)
|
||||
const dynamicRoutes = convertMenusToRoutes(menus);
|
||||
|
||||
if (router.hasRoute('Main')) {
|
||||
router.removeRoute('Main');
|
||||
}
|
||||
|
||||
// 重新添加主路由,合并静态子路由和动态路由
|
||||
router.addRoute({
|
||||
path: "/",
|
||||
name: "Main",
|
||||
component: () => import("@/views/Main.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [...staticMainChildren, ...dynamicRoutes] // 合并静态和动态子路由
|
||||
});
|
||||
|
||||
dynamicRoutesAdded = true;
|
||||
}
|
||||
|
||||
function findRouteByName(routes, routeName) {
|
||||
for (const route of routes) {
|
||||
if (route.name === routeName) {
|
||||
return route;
|
||||
}
|
||||
if (route.children) {
|
||||
const found = findRouteByName(route.children, routeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstValidRoute(routes) {
|
||||
for (const route of routes) {
|
||||
if (route.component) {
|
||||
return route;
|
||||
}
|
||||
if (route.children && route.children.length > 0) {
|
||||
const childRoute = findFirstValidRoute(route.children);
|
||||
if (childRoute) {
|
||||
return childRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const publicPaths = ["/login", "/register", "/forget"];
|
||||
|
||||
if (publicPaths.includes(to.path)) {
|
||||
if (token) {
|
||||
if (!dynamicRoutesAdded) {
|
||||
await loadAndAddDynamicRoutes();
|
||||
}
|
||||
next({ path: "/home" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
next({ path: "/login", query: { redirect: to.path } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dynamicRoutesAdded) {
|
||||
await loadAndAddDynamicRoutes();
|
||||
// 路由加载后重新导航,确保路由匹配正确
|
||||
next({ path: to.path, replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||
import { redirectToAuthorize, ensureUserInfo } from "@/utils/authClient";
|
||||
|
||||
// 本地登录已停用的历史路径:命中后统一改送认证中心,避免书签/后端回跳落到 404
|
||||
const LEGACY_LOCAL_LOGIN_PATHS = ["/login", "/register", "/forget"];
|
||||
|
||||
|
||||
// 静态子路由:需要在 Main 框架内显示的页面
|
||||
const staticMainChildren = [
|
||||
// 默认首页:访问 / 时直接展示工作台,URL 保持为 /
|
||||
{
|
||||
path: "",
|
||||
name: "Dashboard",
|
||||
component: () => import("@/views/dashboard/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作台" }
|
||||
},
|
||||
// 审批流配置(租户侧):静态注册保证 URL 直达,菜单入口由数据库驱动
|
||||
{
|
||||
path: "/system/approval",
|
||||
name: "BackendApprovalFlow",
|
||||
component: () => import("@/views/system/approval/index.vue"),
|
||||
meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" }
|
||||
},
|
||||
// ERP 工作台目录内的两个页面:静态注册保证 URL 直达,菜单入口由数据库驱动
|
||||
{
|
||||
path: "/apps/erp/dashboard",
|
||||
name: "ErpWorkbenchDashboard",
|
||||
component: () => import("@/views/apps/erp/dashboard/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作台", modulePath: "/apps/erp" }
|
||||
},
|
||||
{
|
||||
path: "/apps/erp/workbench/approval",
|
||||
name: "ErpWorkbenchApprovalList",
|
||||
component: () => import("@/views/apps/erp/workbench/approvalList.vue"),
|
||||
meta: { requiresAuth: true, title: "审批列表", modulePath: "/apps/erp" }
|
||||
},
|
||||
// OA 审批中心:待我审批 / 审批列表 / 审批设置(静态注册保证 URL 直达)
|
||||
{
|
||||
path: "/apps/oa/approvalcenter/mine",
|
||||
name: "OaApprovalCenterMine",
|
||||
component: () => import("@/views/apps/oa/approvalcenter/mine.vue"),
|
||||
meta: { requiresAuth: true, title: "待我审批", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/approvalcenter/list",
|
||||
name: "OaApprovalCenterList",
|
||||
component: () => import("@/views/apps/oa/approvalcenter/list.vue"),
|
||||
meta: { requiresAuth: true, title: "审批列表", modulePath: "/apps/oa" }
|
||||
},
|
||||
{
|
||||
path: "/apps/oa/approvalcenter/settings",
|
||||
name: "OaApprovalCenterSettings",
|
||||
component: () => import("@/views/apps/oa/approvalcenter/settings.vue"),
|
||||
meta: { requiresAuth: true, title: "审批设置", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 工作排班(静态注册兜底:三级菜单在动态路由重建前/菜单缓存未刷新时直链会落到 404)
|
||||
{
|
||||
path: "/apps/oa/scheduling/board",
|
||||
name: "OaSchedulingBoard",
|
||||
component: () => import("@/views/apps/oa/scheduling/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 考勤管理(移动端打卡产生的数据,后台汇总 / 明细 / 备注)
|
||||
{
|
||||
path: "/apps/oa/attendance",
|
||||
name: "OaAttendance",
|
||||
component: () => import("@/views/apps/oa/attendance/index.vue"),
|
||||
meta: { requiresAuth: true, title: "考勤管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA AI办公(AI 广场):三级菜单直链兜底
|
||||
{
|
||||
path: "/apps/oa/ai",
|
||||
name: "OaAiPlayground",
|
||||
component: () => import("@/views/apps/oa/ai/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI办公", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 基础设置 → AI设置(租户级接入配置)
|
||||
{
|
||||
path: "/basic-settings/ai",
|
||||
name: "BasicSettingsAi",
|
||||
component: () => import("@/views/basicSettings/ai/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI设置", modulePath: "/apps/oa" }
|
||||
},
|
||||
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
||||
// 统一结构:index 为列表页,type 为分类页。
|
||||
{
|
||||
path: "/apps/cms/analytics/content",
|
||||
name: "CmsContentAnalytics",
|
||||
component: () => import("@/views/apps/cms/analytics/content/index.vue"),
|
||||
meta: { requiresAuth: true, title: "内容统计", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/article",
|
||||
name: "CmsArticles",
|
||||
component: () => import("@/views/apps/cms/article/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "文章管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/article/type",
|
||||
name: "CmsArticleCategories",
|
||||
component: () => import("@/views/apps/cms/article/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "文章分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/products",
|
||||
name: "CmsProducts",
|
||||
component: () => import("@/views/apps/cms/product/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/products/types",
|
||||
name: "CmsProductCategories",
|
||||
component: () => import("@/views/apps/cms/product/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/solutions",
|
||||
name: "CmsSolutions",
|
||||
component: () => import("@/views/apps/cms/solution/index/index.vue"),
|
||||
meta: { requiresAuth: true, title: "解决方案管理", modulePath: "/apps/cms" }
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/solutions/types",
|
||||
name: "CmsSolutionCategories",
|
||||
component: () => import("@/views/apps/cms/solution/type/index.vue"),
|
||||
meta: { requiresAuth: true, title: "解决方案分类", modulePath: "/apps/cms" }
|
||||
},
|
||||
// CRM 产品管理(产品台账/目录):项目生成时自动写入产品参数
|
||||
{
|
||||
path: "/apps/crm/product",
|
||||
name: "CrmProduct",
|
||||
component: () => import("@/views/apps/crm/product/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/crm" }
|
||||
},
|
||||
// 兼容旧路径:articles/* -> article/*
|
||||
{
|
||||
path: "/apps/cms/articles",
|
||||
redirect: "/apps/cms/article"
|
||||
},
|
||||
{
|
||||
path: "/apps/cms/articles/category",
|
||||
redirect: "/apps/cms/article/type"
|
||||
},
|
||||
// CRM 产品分类(用户自定义产品归类):产品表单的分类下拉取自该表
|
||||
{
|
||||
path: "/apps/crm/product/cate",
|
||||
name: "CrmProductCategory",
|
||||
component: () => import("@/views/apps/crm/product/cate.vue"),
|
||||
meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/crm" }
|
||||
},
|
||||
// 兼容带 .vue 后缀的菜单路径
|
||||
{
|
||||
path: "/apps/crm/product/cate.vue",
|
||||
redirect: "/apps/crm/product/cate"
|
||||
},
|
||||
// CRM 基本设置(单位设置等基础数据维护入口)
|
||||
{
|
||||
path: "/apps/crm/setting",
|
||||
name: "CrmSetting",
|
||||
component: () => import("@/views/apps/crm/setting/index.vue"),
|
||||
meta: { requiresAuth: true, title: "基本设置", modulePath: "/apps/crm" }
|
||||
},
|
||||
// CRM 单位设置(产品等业务的「单位」下拉取自该表)
|
||||
{
|
||||
path: "/apps/crm/setting/unit",
|
||||
name: "CrmSettingUnit",
|
||||
component: () => import("@/views/apps/crm/setting/unit/index.vue"),
|
||||
meta: { requiresAuth: true, title: "单位设置", modulePath: "/apps/crm" }
|
||||
},
|
||||
// 兼容带 .vue 后缀的菜单路径
|
||||
{
|
||||
path: "/apps/crm/setting/unit.vue",
|
||||
redirect: "/apps/crm/setting/unit"
|
||||
},
|
||||
{
|
||||
path: "/user/userProfile",
|
||||
name: "userProfile",
|
||||
component: () => import("@/views/user/userProfile.vue"),
|
||||
meta: { requiresAuth: true, title: "用户中心" }
|
||||
},
|
||||
{
|
||||
path: "/user/notifyConfig",
|
||||
name: "notifyConfig",
|
||||
component: () => import("@/views/user/notifyConfig.vue"),
|
||||
meta: { requiresAuth: true, title: "通知设置" }
|
||||
},
|
||||
{
|
||||
path: "/apps/ai",
|
||||
name: "aiSquare",
|
||||
component: () => import("@/views/apps/ai/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI广场" }
|
||||
},
|
||||
// 兼容拼写错误的路径重定向
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
redirect: "/apps/erp/dashboard"
|
||||
}
|
||||
];
|
||||
|
||||
// 静态路由:home 导航门户独立、404 页面独立
|
||||
// 注意:本地账号密码登录入口(/login、/register、/forget)已下线,登录一律走统一认证中心
|
||||
const staticRoutes = [
|
||||
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
|
||||
{
|
||||
path: "/auth/callback",
|
||||
name: "AuthCallback",
|
||||
component: () => import("@/views/auth/callback.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// 微信公众号一次性链接登录页(公众号发「登录」/扫码确认 → 链接跳到这里兑换令牌)
|
||||
{
|
||||
path: "/mp-login",
|
||||
name: "MpLogin",
|
||||
component: () => import("@/views/auth/mpLogin.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// 移动端免登录页(uniapp APP / 小程序带一次性票据 → 换发 Web 端令牌,避免二次登录)
|
||||
{
|
||||
path: "/app-login",
|
||||
name: "AppLogin",
|
||||
component: () => import("@/views/auth/appLogin.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
component: () => import("@/views/home/index.vue"),
|
||||
meta: { requiresAuth: true, title: "系统导航", isStandalone: true }
|
||||
},
|
||||
// 兼容路径拼写错误:dashborad -> dashboard
|
||||
{
|
||||
path: "/apps/erp/dashborad",
|
||||
redirect: "/apps/erp/dashboard"
|
||||
},
|
||||
// 产品购买页:home 未开通卡片「购买」按钮跳转,独立于 Main 框架展示
|
||||
{
|
||||
path: "/product/:code",
|
||||
name: "ProductPurchase",
|
||||
component: () => import("@/views/product/index.vue"),
|
||||
meta: { requiresAuth: true, title: "产品购买", isStandalone: true }
|
||||
},
|
||||
// 套餐购买列表页:独立于 Main 框架展示
|
||||
{
|
||||
path: "/packages",
|
||||
name: "PackageList",
|
||||
component: () => import("@/views/package/list.vue"),
|
||||
meta: { requiresAuth: true, title: "套餐购买", isStandalone: true }
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/404/404.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes
|
||||
});
|
||||
|
||||
let dynamicRoutesAdded = false;
|
||||
let dynamicRoutesData = [];
|
||||
let routesLoadingPromise = null;
|
||||
|
||||
export function resetDynamicRoutes() {
|
||||
dynamicRoutesAdded = false;
|
||||
routesLoadingPromise = null;
|
||||
}
|
||||
|
||||
export async function loadAndAddDynamicRoutes() {
|
||||
if (routesLoadingPromise) {
|
||||
return routesLoadingPromise;
|
||||
}
|
||||
|
||||
if (dynamicRoutesAdded) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
routesLoadingPromise = (async () => {
|
||||
try {
|
||||
const { useMenuStore } = await import("@/stores/menu");
|
||||
const menuStore = useMenuStore();
|
||||
const menuData = await menuStore.fetchMenus();
|
||||
|
||||
if (menuData && menuData.length > 0) {
|
||||
addDynamicRoutes(menuData);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
// 即使权限菜单为空,也要注册内置文章中心路由。
|
||||
addDynamicRoutes([]);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
// 菜单接口异常时不要把错误抛给导航守卫,否则应用会卡在白屏;
|
||||
// 保留内置静态路由(工作台、审批列表、审批流配置等)继续可用。
|
||||
// 不重置 dynamicRoutesAdded,避免每次路由切换都重试失败的接口,
|
||||
// 刷新页面或手动刷新菜单即可重新拉取。
|
||||
console.error('加载动态路由失败,已保留内置路由:', error);
|
||||
dynamicRoutesAdded = true;
|
||||
routesLoadingPromise = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
})();
|
||||
|
||||
return routesLoadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新拉取菜单并重建动态路由。
|
||||
* 动态路由在构建时会把「未开通模块」的菜单注册为锁定占位页(views/locked),
|
||||
* 仅调用 menuStore.refreshMenus() 只更新菜单数据、不会重建路由,
|
||||
* 因此支付开通/套餐变更后必须调用本方法,才能让占位页切换为真实页面。
|
||||
*/
|
||||
export async function reloadMenusAndDynamicRoutes() {
|
||||
const { useMenuStore } = await import("@/stores/menu");
|
||||
const menuStore = useMenuStore();
|
||||
// 强制从接口拉取最新菜单(含 locked 标记),写入 store 与缓存
|
||||
await menuStore.refreshMenus();
|
||||
// 重置加载标志后重建路由表
|
||||
resetDynamicRoutes();
|
||||
await loadAndAddDynamicRoutes();
|
||||
}
|
||||
|
||||
// 核心修改:移除扁平化,直接使用嵌套菜单生成路由
|
||||
function addDynamicRoutes(menus) {
|
||||
if (!menus?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接转换嵌套菜单为嵌套路由(不再扁平化)
|
||||
const dynamicRoutes = convertMenusToRoutes(menus);
|
||||
|
||||
if (router.hasRoute('Main')) {
|
||||
router.removeRoute('Main');
|
||||
}
|
||||
|
||||
// 重新添加主路由,合并静态子路由和动态路由
|
||||
router.addRoute({
|
||||
path: "/",
|
||||
name: "Main",
|
||||
component: () => import("@/views/Main.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [...staticMainChildren, ...dynamicRoutes] // 合并静态和动态子路由
|
||||
});
|
||||
|
||||
dynamicRoutesAdded = true;
|
||||
}
|
||||
|
||||
function findRouteByName(routes, routeName) {
|
||||
for (const route of routes) {
|
||||
if (route.name === routeName) {
|
||||
return route;
|
||||
}
|
||||
if (route.children) {
|
||||
const found = findRouteByName(route.children, routeName);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstValidRoute(routes) {
|
||||
for (const route of routes) {
|
||||
if (route.component) {
|
||||
return route;
|
||||
}
|
||||
if (route.children && route.children.length > 0) {
|
||||
const childRoute = findFirstValidRoute(route.children);
|
||||
if (childRoute) {
|
||||
return childRoute;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 跳转统一认证中心;地址未配置/检测到循环时给出提示,避免白屏或无限跳转 */
|
||||
async function gotoAuthorize() {
|
||||
try {
|
||||
await redirectToAuthorize();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
window.alert(e?.message || "统一认证跳转失败,请检查认证中心配置");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 认证中心回跳页 / 微信登录兑换页 / 移动端免登录页:始终放行,由页面自身完成令牌兑换
|
||||
if (
|
||||
to.path === "/auth/callback" ||
|
||||
to.path === "/mp-login" ||
|
||||
to.path === "/app-login"
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 已下线的本地登录入口:已登录回首页,未登录送认证中心
|
||||
if (LEGACY_LOCAL_LOGIN_PATHS.includes(to.path)) {
|
||||
if (token) {
|
||||
if (!dynamicRoutesAdded) {
|
||||
await loadAndAddDynamicRoutes();
|
||||
}
|
||||
next({ path: "/home" });
|
||||
} else {
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 未登录:统一走认证中心(本地登录已停用)
|
||||
if (!token) {
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 补全用户信息(旧会话可能只有 token 没有 userInfo,或迁移后 userInfo 缺失 id,
|
||||
// 会导致菜单等接口报「用户ID不存在」)。
|
||||
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
|
||||
const info = await ensureUserInfo();
|
||||
if (!info) {
|
||||
// 只有登录态确实被清空(确认令牌失效)才重新授权;userinfo 接口临时故障
|
||||
// 时保留 token 放行,避免「登录成功 → 接口抖动 → 回认证中心」的循环。
|
||||
if (localStorage.getItem("token")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const ok = await gotoAuthorize();
|
||||
if (!ok) next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dynamicRoutesAdded) {
|
||||
await loadAndAddDynamicRoutes();
|
||||
// 路由加载后重新导航,确保路由匹配正确
|
||||
next({ path: to.path, replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 菜单默认落地页规则。
|
||||
*
|
||||
* 模块根菜单本身通常是目录,点击模块(如首页的 ERP 卡片)后由这里的规则
|
||||
* 决定跳到哪个子菜单。规则按「工作台优先」收敛,避免依赖后端返回顺序
|
||||
* 而落到某个先创建的页面(例如 ERP 的采购订单)。
|
||||
*/
|
||||
|
||||
/** 工作台/仪表盘入口的识别规则(标题、菜单路径、组件路径) */
|
||||
const WORKBENCH_TITLE_RE = /工作台|仪表盘/;
|
||||
const WORKBENCH_PATH_RE = /(^|\/)(dashboard|workbench)(\/|\.|$)/i;
|
||||
|
||||
export function isWorkbenchMenu(menu) {
|
||||
if (!menu) return false;
|
||||
return (
|
||||
WORKBENCH_TITLE_RE.test(String(menu.title || '')) ||
|
||||
WORKBENCH_PATH_RE.test(String(menu.path || '')) ||
|
||||
WORKBENCH_PATH_RE.test(String(menu.component_path || ''))
|
||||
);
|
||||
}
|
||||
|
||||
/** 按 sort(其次 id)升序:后端菜单按 ID 返回,与侧栏按 sort 展示的顺序可能不一致 */
|
||||
export function sortByMenuOrder(menus) {
|
||||
return [...menus].sort((a, b) => {
|
||||
const sortA = Number(a.sort) || 0;
|
||||
const sortB = Number(b.sort) || 0;
|
||||
if (sortA !== sortB) return sortA - sortB;
|
||||
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块默认落地页:统一优先「工作台 / 仪表盘」,其次侧栏排在最前的子菜单。
|
||||
*
|
||||
* 直接取 children[0] 不可靠:后端按 ID 顺序返回,而侧栏是按 sort 展示的。
|
||||
* 早期先创建的页面(例如 ERP 的采购订单)会因此成为模块默认页,
|
||||
* 即使「工作台」已经排在 sort=1。
|
||||
*/
|
||||
export function pickDefaultChild(children) {
|
||||
if (!children || children.length === 0) return null;
|
||||
const sorted = sortByMenuOrder(children);
|
||||
const workbench =
|
||||
sorted.find(isWorkbenchMenu) ||
|
||||
// 工作台被放成二级目录(如 ERP:工作台 -> 仪表盘)时,选这个目录,
|
||||
// 由该目录自己的 redirect 落到具体工作台页面
|
||||
sorted.find((child) => Array.isArray(child.children) && child.children.some(isWorkbenchMenu));
|
||||
return workbench || sorted[0];
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { getCurrentUser } from '@/api/login'
|
||||
import { logoutSSO, isAuthHandoff } from '@/utils/authClient'
|
||||
|
||||
// 用户信息类型
|
||||
const defaultUser = {
|
||||
@@ -8,6 +10,7 @@ const defaultUser = {
|
||||
name: '',
|
||||
group_id: '',
|
||||
type: 'backend',
|
||||
tenant_name: '',
|
||||
avatar: ''
|
||||
}
|
||||
|
||||
@@ -29,8 +32,32 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 从后端拉取最新用户信息(含租户名称),用于已有会话补充 tenant_name
|
||||
async function fetchCurrentUser() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const res = await getCurrentUser()
|
||||
if (res && res.code === 200 && res.data) {
|
||||
// 后端没给出企业名时不要用空值覆盖本地已有的名称,
|
||||
// 否则顶部「当前企业」会突然消失(统一认证会话下尤其容易出现)
|
||||
const data = { ...res.data }
|
||||
if (!data.tenant_name) delete data.tenant_name
|
||||
Object.assign(user, data)
|
||||
localStorage.setItem('userInfo', JSON.stringify({ ...user }))
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不影响登录态
|
||||
console.error('Failed to fetch current user info:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时加载用户信息
|
||||
loadUserFromCache()
|
||||
// 若已登录但缺少租户名称,异步从后端补齐;
|
||||
// 认证交接页除外(此时 token 是旧的,请求会 401 并干扰换码)
|
||||
if (token.value && !isAuthHandoff()) {
|
||||
fetchCurrentUser()
|
||||
}
|
||||
|
||||
// 保存登录信息(token 和用户信息)
|
||||
function setLoginInfo(loginData) {
|
||||
@@ -42,6 +69,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
name: userInfo.name || '',
|
||||
group_id: userInfo.group_id || '',
|
||||
type: 'backend',
|
||||
tenant_name: userInfo.tenant_name || '',
|
||||
tid: userInfo.tid || '',
|
||||
avatar: userInfo.avatar || ''
|
||||
}
|
||||
@@ -71,6 +99,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
Object.assign(user, defaultUser)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
// 统一认证:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效,
|
||||
// 调用方不要再 router.push('/login'),本地登录页已下线)
|
||||
logoutSSO()
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
@@ -101,7 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setToken,
|
||||
clearToken,
|
||||
checkAuth,
|
||||
updateUserInfo
|
||||
updateUserInfo,
|
||||
fetchCurrentUser
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getDictOptions, getDictOptionsBatch, getAllDictOptions } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 字典 Store(平台端)
|
||||
*
|
||||
* 全部字典数据都来自数据库,前端不维护编码清单:
|
||||
* - 登录后调用一次 loadAll(),把库里所有「启用」字典读进缓存;
|
||||
* - 页面按库里真实的 dict_code 取值即可(字符串直接写,无需常量文件);
|
||||
* - 之后在字典管理页新增的字典,第一次被引用时会按需自动加载。
|
||||
*
|
||||
* 用法:
|
||||
* const dictStore = useDictStore()
|
||||
* dictStore.getDictLabel('payment_order_status', row.status) // 文案
|
||||
* dictStore.getDictTagType('payment_order_status', row.status) // 标签色
|
||||
* dictStore.getSelectOptions('payment_channel') // 下拉选项
|
||||
*/
|
||||
export const useDictStore = defineStore('dict', () => {
|
||||
/** dict_code -> 字典项数组(渲染依赖它,必须是响应式的) */
|
||||
const dictMap = ref({})
|
||||
|
||||
/** 已加载过的编码(仅用于去重,不参与渲染) */
|
||||
let loaded = {}
|
||||
/** 正在请求中的编码 -> Promise(并发调用复用同一个请求) */
|
||||
const pending = new Map()
|
||||
/** 是否已完成全量预热 */
|
||||
let allLoaded = false
|
||||
let allPending = null
|
||||
|
||||
function setItems(code, list) {
|
||||
dictMap.value = { ...dictMap.value, [code]: Array.isArray(list) ? list : [] }
|
||||
}
|
||||
|
||||
function itemsOf(code) {
|
||||
return dictMap.value[code] || []
|
||||
}
|
||||
|
||||
/** 把入参规范成去重后的编码数组 */
|
||||
function normalizeCodes(codes) {
|
||||
return [...new Set((codes || []).map((c) => String(c || '').trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载字典(带缓存与并发去重)。
|
||||
* 请求失败也会标记为已加载,避免页面反复重试;需要重试请先 clearCache。
|
||||
*/
|
||||
async function load(codes) {
|
||||
const list = normalizeCodes(codes)
|
||||
const missing = list.filter((c) => !loaded[c] && !pending.has(c))
|
||||
|
||||
if (missing.length) {
|
||||
const promise = (async () => {
|
||||
if (missing.length === 1) {
|
||||
// 单个字典走专用接口
|
||||
const code = missing[0]
|
||||
try {
|
||||
const res = await getDictOptions(code)
|
||||
setItems(code, res?.code === 200 ? res.data?.list || [] : [])
|
||||
} catch {
|
||||
setItems(code, [])
|
||||
}
|
||||
} else {
|
||||
// 多个字典合并成一次请求
|
||||
try {
|
||||
const res = await getDictOptionsBatch(missing)
|
||||
const dicts = res?.code === 200 ? res.data?.dicts || {} : {}
|
||||
missing.forEach((code) => setItems(code, dicts[code] || []))
|
||||
} catch {
|
||||
missing.forEach((code) => setItems(code, []))
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
missing.forEach((code) => {
|
||||
loaded[code] = true
|
||||
pending.delete(code)
|
||||
})
|
||||
})
|
||||
|
||||
missing.forEach((code) => pending.set(code, promise))
|
||||
}
|
||||
|
||||
await Promise.all(list.map((c) => pending.get(c) || Promise.resolve()))
|
||||
return list.flatMap((c) => itemsOf(c))
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量预热:一次请求把库里所有启用字典读进缓存(登录后调用一次即可)。
|
||||
* 之后再新增的字典仍会走 load() 按需加载。
|
||||
*/
|
||||
function loadAll() {
|
||||
if (allLoaded) return Promise.resolve()
|
||||
if (allPending) return allPending
|
||||
|
||||
allPending = (async () => {
|
||||
try {
|
||||
const res = await getAllDictOptions()
|
||||
const dicts = res?.code === 200 ? res.data?.dicts || {} : {}
|
||||
const next = { ...dictMap.value }
|
||||
Object.entries(dicts).forEach(([code, list]) => {
|
||||
loaded[code] = true
|
||||
next[code] = Array.isArray(list) ? list : []
|
||||
})
|
||||
dictMap.value = next
|
||||
} catch {
|
||||
// 预热失败不影响使用:后续按需加载
|
||||
} finally {
|
||||
allLoaded = true
|
||||
allPending = null
|
||||
}
|
||||
})()
|
||||
return allPending
|
||||
}
|
||||
|
||||
/** 同步触发加载(不等待),用于组件 setup/onMounted 里“先渲染后填充” */
|
||||
function ensure(code) {
|
||||
const list = normalizeCodes([code])
|
||||
if (list.some((c) => !loaded[c] && !pending.has(c))) {
|
||||
load(list)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步取字典项;未加载时顺带触发加载,拿到后由响应式自动刷新 */
|
||||
function getDictItemsSync(code) {
|
||||
ensure(code)
|
||||
return itemsOf(code)
|
||||
}
|
||||
|
||||
/** 值 → 字典项(值按字符串比较,兼容 1 / '1' 混用) */
|
||||
function getDictItem(code, value) {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
const key = String(value)
|
||||
return itemsOf(code).find((it) => String(it.item_value) === key) || null
|
||||
}
|
||||
|
||||
/** 值 → 文案;取不到时返回 fallback,未传则原样返回取值 */
|
||||
function getDictLabel(code, value, fallback) {
|
||||
const hit = getDictItem(code, value)
|
||||
if (hit) return hit.item_label
|
||||
if (fallback !== undefined) return fallback
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
/** 值 → el-tag 的 type;取不到时返回 fallback(默认 info) */
|
||||
function getDictTagType(code, value, fallback = 'info') {
|
||||
const hit = getDictItem(code, value)
|
||||
return hit && hit.tag_type ? hit.tag_type : fallback
|
||||
}
|
||||
|
||||
/** 值 → 下拉选项 { label, value },供 el-select / el-option 直接使用 */
|
||||
function getSelectOptions(code) {
|
||||
return getDictItemsSync(code).map((it) => ({ label: it.item_label, value: it.item_value, tag_type: it.tag_type }))
|
||||
}
|
||||
|
||||
/** 预加载多个字典(一次批量请求) */
|
||||
function preloadDicts(codes) {
|
||||
return load(codes)
|
||||
}
|
||||
|
||||
/** 清空缓存:传编码清单个,不传清全部(同时允许重新全量预热) */
|
||||
function clearCache(code) {
|
||||
if (code) {
|
||||
delete loaded[code]
|
||||
const next = { ...dictMap.value }
|
||||
delete next[code]
|
||||
dictMap.value = next
|
||||
return
|
||||
}
|
||||
loaded = {}
|
||||
dictMap.value = {}
|
||||
allLoaded = false
|
||||
}
|
||||
|
||||
return {
|
||||
dictMap,
|
||||
ensure,
|
||||
loadAll,
|
||||
getDictItems: load,
|
||||
getDictItemsSync,
|
||||
getDictItem,
|
||||
getDictLabel,
|
||||
getDictTagType,
|
||||
getSelectOptions,
|
||||
preloadDicts,
|
||||
clearCache
|
||||
}
|
||||
})
|
||||
+61
-28
@@ -58,27 +58,23 @@ import { ref as vueRef } from 'vue';
|
||||
* }
|
||||
*/
|
||||
export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
// 固定首页tab
|
||||
const defaultDashboardPath = '/home';
|
||||
// 默认标签页:不再固定「首页」,而是由 Main.vue 按当前模块设置成仪表盘
|
||||
const defaultDashboardPath = vueRef('');
|
||||
|
||||
// 从 localStorage 恢复 tabs 状态
|
||||
// 从 localStorage 恢复 tabs 状态(兼容旧版本里遗留的 /home 首页标签)
|
||||
function loadTabsFromStorage() {
|
||||
try {
|
||||
const savedTabs = localStorage.getItem('tabs_list');
|
||||
const savedActiveTab = localStorage.getItem('active_tab');
|
||||
if (savedTabs) {
|
||||
const tabs = JSON.parse(savedTabs);
|
||||
// 确保至少包含首页
|
||||
const hasHome = tabs.some(t => t.fullPath === defaultDashboardPath);
|
||||
if (!hasHome) {
|
||||
tabs.unshift({ title: '首页', fullPath: defaultDashboardPath, name: 'Home' });
|
||||
if (Array.isArray(tabs)) {
|
||||
return tabs.filter(t => t && t.fullPath && t.fullPath !== '/home');
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('恢复 tabs 失败:', e);
|
||||
}
|
||||
return [{ title: '首页', fullPath: defaultDashboardPath, name: 'Home' }];
|
||||
return [];
|
||||
}
|
||||
|
||||
// 保存 tabs 到 localStorage
|
||||
@@ -95,7 +91,40 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
|
||||
const tabList = vueRef(loadTabsFromStorage());
|
||||
const savedActiveTab = localStorage.getItem('active_tab');
|
||||
const activeTab = vueRef(savedActiveTab || defaultDashboardPath);
|
||||
const activeTab = vueRef(
|
||||
savedActiveTab && savedActiveTab !== '/home' ? savedActiveTab : ''
|
||||
);
|
||||
|
||||
// 设置当前模块的默认仪表盘标签:始终排在第一位且不可关闭
|
||||
function setDefaultTab(tab) {
|
||||
const path = tab?.fullPath || '';
|
||||
if (!path) return;
|
||||
const prevDefault = defaultDashboardPath.value;
|
||||
defaultDashboardPath.value = path;
|
||||
|
||||
// 默认工作台路径变化 = 切换了模块:关闭旧模块的全部 tab,
|
||||
// 只保留新模块的默认工作台。
|
||||
// prevDefault 为空(首次设置,如刷新后恢复)时不清空,保留恢复出来的 tabs。
|
||||
if (prevDefault && prevDefault !== path) {
|
||||
tabList.value = [tab];
|
||||
activeTab.value = path;
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = tabList.value.findIndex(t => t.fullPath === path);
|
||||
if (idx === -1) {
|
||||
tabList.value.unshift(tab);
|
||||
} else if (idx > 0) {
|
||||
const [exist] = tabList.value.splice(idx, 1);
|
||||
tabList.value.unshift(exist);
|
||||
}
|
||||
|
||||
if (!activeTab.value || !tabList.value.some(t => t.fullPath === activeTab.value)) {
|
||||
activeTab.value = path;
|
||||
}
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
}
|
||||
|
||||
// 添加tab,若已存在则激活
|
||||
function addTab(tab) {
|
||||
@@ -107,8 +136,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
}
|
||||
|
||||
// 删除指定tab并切换激活tab
|
||||
// 删除指定tab并切换激活tab(默认仪表盘标签不可关闭)
|
||||
function removeTab(fullPath) {
|
||||
if (fullPath === defaultDashboardPath.value) return;
|
||||
const idx = tabList.value.findIndex((t) => t.fullPath === fullPath);
|
||||
if (idx > -1) {
|
||||
tabList.value.splice(idx, 1);
|
||||
@@ -119,33 +149,31 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
const newIdx = idx >= tabList.value.length ? tabList.value.length - 1 : idx;
|
||||
activeTab.value = tabList.value[newIdx].fullPath;
|
||||
} else {
|
||||
// 全部关闭,兜底首页
|
||||
activeTab.value = defaultDashboardPath;
|
||||
activeTab.value = defaultDashboardPath.value;
|
||||
}
|
||||
}
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭其他,只留首页和当前激活tab
|
||||
// 关闭其他,只留默认仪表盘和当前激活tab
|
||||
function closeOthers() {
|
||||
tabList.value = tabList.value.filter(
|
||||
(t) => t.fullPath === defaultDashboardPath || t.fullPath === activeTab.value
|
||||
(t) => t.fullPath === defaultDashboardPath.value || t.fullPath === activeTab.value
|
||||
);
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
}
|
||||
|
||||
// 关闭左侧(关闭指定tab左侧的所有tab,保留首页和目标tab)
|
||||
// 关闭左侧(关闭指定tab左侧的所有tab,保留默认仪表盘和目标tab)
|
||||
function closeLeft(targetFullPath) {
|
||||
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
||||
if (targetIndex > -1) {
|
||||
// 保留首页和目标tab及其右侧的所有tab
|
||||
const beforeIndex = tabList.value.slice(0, targetIndex);
|
||||
const hasCloseableLeft = beforeIndex.some(t => t.fullPath !== defaultDashboardPath);
|
||||
const hasCloseableLeft = beforeIndex.some(t => t.fullPath !== defaultDashboardPath.value);
|
||||
|
||||
if (hasCloseableLeft) {
|
||||
tabList.value = tabList.value.filter((t, index) =>
|
||||
t.fullPath === defaultDashboardPath || index >= targetIndex
|
||||
t.fullPath === defaultDashboardPath.value || index >= targetIndex
|
||||
);
|
||||
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
||||
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
||||
@@ -156,17 +184,16 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭右侧(关闭指定tab右侧的所有tab,保留首页和目标tab)
|
||||
// 关闭右侧(关闭指定tab右侧的所有tab,保留默认仪表盘和目标tab)
|
||||
function closeRight(targetFullPath) {
|
||||
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
||||
if (targetIndex > -1) {
|
||||
// 保留首页和目标tab及其左侧的所有tab
|
||||
const afterIndex = tabList.value.slice(targetIndex + 1);
|
||||
const hasCloseableRight = afterIndex.length > 0;
|
||||
|
||||
if (hasCloseableRight) {
|
||||
tabList.value = tabList.value.filter((t, index) =>
|
||||
t.fullPath === defaultDashboardPath || index <= targetIndex
|
||||
t.fullPath === defaultDashboardPath.value || index <= targetIndex
|
||||
);
|
||||
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
||||
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
||||
@@ -177,10 +204,13 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭全部,只留首页
|
||||
// 关闭全部,只留默认仪表盘
|
||||
function closeAll() {
|
||||
tabList.value = tabList.value.filter((t) => t.fullPath === defaultDashboardPath);
|
||||
activeTab.value = defaultDashboardPath;
|
||||
const target = defaultDashboardPath.value || activeTab.value;
|
||||
tabList.value = target
|
||||
? tabList.value.filter((t) => t.fullPath === target)
|
||||
: [];
|
||||
activeTab.value = tabList.value.length ? target : '';
|
||||
saveTabsToStorage(tabList.value, activeTab.value);
|
||||
}
|
||||
|
||||
@@ -192,8 +222,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
|
||||
// 重置 tabs store 到初始状态(登出时使用)
|
||||
function resetTabs() {
|
||||
tabList.value = [{ title: '首页', fullPath: defaultDashboardPath, name: 'Dashboard' }];
|
||||
activeTab.value = defaultDashboardPath;
|
||||
tabList.value = [];
|
||||
activeTab.value = '';
|
||||
defaultDashboardPath.value = '';
|
||||
// 清除 localStorage 中的 tabs 数据
|
||||
localStorage.removeItem('tabs_list');
|
||||
localStorage.removeItem('active_tab');
|
||||
@@ -202,7 +233,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
||||
return {
|
||||
tabList,
|
||||
activeTab,
|
||||
defaultDashboardPath,
|
||||
addTab,
|
||||
setDefaultTab,
|
||||
removeTab,
|
||||
closeOthers,
|
||||
closeLeft,
|
||||
|
||||
+31
-12
@@ -3,6 +3,14 @@ import { ref, computed } from 'vue'
|
||||
// import { getUserInfo } from '@/utils/auth'
|
||||
import { getMenus } from '@/api/menu';
|
||||
|
||||
// 是否属于「登录态失效」类错误。
|
||||
// request.js 已给鉴权错误打上 status=401,同时兼容历史文案与 axios 原始错误。
|
||||
const isAuthError = (err) =>
|
||||
err?.status === 401 ||
|
||||
err?.response?.status === 401 ||
|
||||
err?.isAuthError === true ||
|
||||
/token已过期|token无效|无效的token/.test(err?.message || '');
|
||||
|
||||
export const useMenuStore = defineStore('menu', () => {
|
||||
// 菜单数据
|
||||
const menus = ref([]);
|
||||
@@ -16,13 +24,16 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
// 正在加载的 Promise(用于避免重复请求)
|
||||
let loadingPromise = null;
|
||||
|
||||
// 菜单缓存 key(基于用户类型和角色ID)
|
||||
// 菜单缓存 key(基于用户类型、当前企业与角色ID)
|
||||
// 必须带 tid:菜单是按企业下发的,key 里没有企业会让「切换企业」后
|
||||
// 直接复用上一家的菜单,导致新企业未开通的模块仍能进入。
|
||||
const getCacheKey = () => {
|
||||
try {
|
||||
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
const loginType = userInfo.type || 'user';
|
||||
const roleId = userInfo.group_id || 0;
|
||||
return `menu_cache_${loginType}_${roleId}`;
|
||||
const tid = userInfo.tid || 0;
|
||||
return `menu_cache_${loginType}_${tid}_${roleId}`;
|
||||
} catch (e) {
|
||||
return 'menu_cache_default';
|
||||
}
|
||||
@@ -121,9 +132,11 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
// 用户登录,使用 getMenus 接口
|
||||
res = await getMenus(userInfo.id);
|
||||
|
||||
// 检查响应格式
|
||||
// 检查响应格式:HTTP 200 但 body 为空(后端/网关异常)会走到这里,
|
||||
// 打印原始值便于区分「服务挂了」和「返回了空内容」
|
||||
if (!res) {
|
||||
throw new Error('获取菜单失败:服务器无响应');
|
||||
console.error('菜单接口返回空响应:', { res, url: `/backend/menu/${userInfo.id}` });
|
||||
throw new Error('获取菜单失败:接口返回空响应(后端服务或网关异常)');
|
||||
}
|
||||
|
||||
// 检查后端返回的 code 字段
|
||||
@@ -164,6 +177,18 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
throw new Error(res.message || '获取菜单失败:响应格式错误');
|
||||
} catch (err) {
|
||||
error.value = err.message || '获取菜单失败';
|
||||
|
||||
// 鉴权失败(令牌过期/无效、会话被吊销):清掉缓存并继续抛出,
|
||||
// 由 request.js 静默续期或跳认证中心。
|
||||
// 不能回退到缓存:否则令牌过期时会用旧菜单把登录态问题掩盖掉,
|
||||
// 用户看到的是一份已经失效的权限菜单。
|
||||
if (isAuthError(err)) {
|
||||
clearCache();
|
||||
menus.value = [];
|
||||
console.warn('获取菜单失败(登录状态失效):', err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.error('获取菜单失败:', err);
|
||||
console.error('错误详情:', {
|
||||
message: err.message,
|
||||
@@ -171,13 +196,6 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
stack: err.stack
|
||||
});
|
||||
|
||||
// 如果是 token 无效错误,不使用缓存,直接抛出
|
||||
if (err.message === 'token无效' || err.response?.status === 401) {
|
||||
clearCache();
|
||||
menus.value = [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 如果出错,尝试使用缓存数据
|
||||
const cachedMenus = loadFromCache();
|
||||
if (cachedMenus && cachedMenus.length > 0) {
|
||||
@@ -199,8 +217,9 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
};
|
||||
|
||||
// 刷新菜单(强制从 API 获取)
|
||||
// 不预先清缓存:接口异常时 fetchMenus 还能回退到上一份菜单,避免整个应用没菜单可用;
|
||||
// 请求成功后 saveToCache 会直接覆盖旧缓存。
|
||||
const refreshMenus = async () => {
|
||||
clearCache();
|
||||
return await fetchMenus(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE
|
||||
*
|
||||
* 本项目已停用本地账号密码登录(/backend/login、/backend/register 等入口均已下线),
|
||||
* 登录、登出、令牌续期一律走认证中心。
|
||||
*
|
||||
* 环境变量(见 .env.development / .env.production):
|
||||
* VITE_AUTH_BASE=https://api.yunzer.cn/auth 认证中心地址,缺省回退到 ${VITE_API_BASE_URL}/auth
|
||||
* VITE_AUTH_CLIENT_ID=yz-backend
|
||||
* VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/auth-callback.html
|
||||
* VITE_AUTH_POST_LOGOUT_URI=https://back.yunzer.cn/#/
|
||||
*
|
||||
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
|
||||
* 因此业务代码无需改动即可带上 Authorization 头。
|
||||
*/
|
||||
|
||||
// 未显式配置时回退到「接口地址 + /auth」,避免跳转到空地址或前端自身域名
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
const AUTH_BASE = (import.meta.env.VITE_AUTH_BASE || (API_BASE ? `${API_BASE}/auth` : "")).replace(
|
||||
/\/+$/,
|
||||
""
|
||||
);
|
||||
const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend";
|
||||
// 缺省回落到静态中转页(不带 #);该地址必须已在认证中心白名单登记
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/auth-callback.html`;
|
||||
const POST_LOGOUT_URI =
|
||||
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/`;
|
||||
|
||||
const ACCESS_KEY = "token"; // 与现有代码保持一致
|
||||
const REFRESH_KEY = "auth_refresh_token";
|
||||
const SID_KEY = "auth_sid";
|
||||
|
||||
/**
|
||||
* 是否启用统一认证。
|
||||
* 本地登录已停用,此处恒为 true;保留函数是为了兼容既有调用点,
|
||||
* 后续清理时可直接删除所有分支判断。
|
||||
*/
|
||||
export function isSSOEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 认证中心地址是否可用(排查配置缺失用) */
|
||||
export function hasAuthBase() {
|
||||
return !!AUTH_BASE;
|
||||
}
|
||||
|
||||
// 认证「交接页」:正在用 code / ticket 兑换令牌。此时 localStorage 里还是
|
||||
// 上一次的旧 token,页面启动期的请求一旦 401,自动恢复逻辑会把刚换到的新
|
||||
// token 清掉并重新跳认证中心,形成「登录成功又回登录页」的循环。
|
||||
const HANDOFF_PATHS = ["/auth/callback", "/app-login", "/mp-login"];
|
||||
const REDIRECT_LOG_KEY = "auth_authorize_redirects";
|
||||
const LOOP_WINDOW_MS = 15000;
|
||||
const LOOP_MAX = 3;
|
||||
|
||||
export function isAuthHandoff() {
|
||||
const hash = window.location.hash || "";
|
||||
const hashPath = hash.startsWith("#") ? hash.slice(1).split("?")[0] : "";
|
||||
if (HANDOFF_PATHS.includes(hashPath)) return true;
|
||||
if (window.location.pathname === "/auth-callback.html") return true;
|
||||
// 兼容中转页把 code/ticket 放在 hash 之前的场景;只看 query,避免业务页面
|
||||
// 自身的 ?token= 等参数被误判成交接页。
|
||||
return /[?&](code|ticket)=/.test(window.location.search || "");
|
||||
}
|
||||
|
||||
// 短时间内反复发起授权说明出现了重定向环:停下并给出可诊断的错误,
|
||||
// 而不是让用户一直被弹回登录界面。
|
||||
function guardAuthorizeLoop() {
|
||||
const now = Date.now();
|
||||
let times = [];
|
||||
try {
|
||||
times = JSON.parse(sessionStorage.getItem(REDIRECT_LOG_KEY) || "[]");
|
||||
} catch (e) {
|
||||
times = [];
|
||||
}
|
||||
times = (Array.isArray(times) ? times : []).filter((t) => now - Number(t) < LOOP_WINDOW_MS);
|
||||
if (times.length >= LOOP_MAX) {
|
||||
throw new Error(
|
||||
"短时间内多次跳转统一登录,已停止自动跳转以避免循环;请检查认证中心与回跳白名单配置后重试"
|
||||
);
|
||||
}
|
||||
times.push(now);
|
||||
try {
|
||||
sessionStorage.setItem(REDIRECT_LOG_KEY, JSON.stringify(times));
|
||||
} catch (e) {
|
||||
// 隐私模式下 sessionStorage 不可写,忽略即可
|
||||
}
|
||||
}
|
||||
|
||||
// 计数按时间窗口自然过期,不在换码成功后清零:否则「换码成功 → 401 → 又跳
|
||||
// 认证中心」这类循环每次都会把计数抹掉,保护永远无法触发。
|
||||
|
||||
// ---------------------------------------------------------------- PKCE
|
||||
|
||||
function randomString(len = 64) {
|
||||
const arr = new Uint8Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join("").slice(0, len);
|
||||
}
|
||||
|
||||
async function sha256(plain) {
|
||||
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain));
|
||||
}
|
||||
|
||||
function base64url(buf) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function generatePKCE() {
|
||||
const verifier = randomString(64);
|
||||
const challenge = base64url(await sha256(verifier));
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 令牌存储
|
||||
|
||||
export function setTokens(data) {
|
||||
if (data?.access_token) localStorage.setItem(ACCESS_KEY, data.access_token);
|
||||
if (data?.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token);
|
||||
if (data?.sid) localStorage.setItem(SID_KEY, data.sid);
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_KEY) || "";
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(SID_KEY);
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_KEY) || "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主流程
|
||||
|
||||
/**
|
||||
* 跳转认证中心登录(带 PKCE 与 state)。
|
||||
*
|
||||
* 入口取认证中心登录页而不是直接打 authorize:
|
||||
* GET {AUTH_BASE}/login?client_id=xxx&redirect=<base64url(authorize URL)>
|
||||
* 认证中心侧约定:
|
||||
* - 未登录 → 展示登录页,登录成功后 302 回 redirect 指向的 authorize;
|
||||
* - 已登录 → 立即 302 回 authorize,由 authorize 识别会话后签发 code;
|
||||
* 最终仍由 authorize 校验 redirect_uri 白名单并携带 code 回跳本端。
|
||||
*/
|
||||
export async function redirectToAuthorize() {
|
||||
if (!AUTH_BASE) {
|
||||
throw new Error("统一认证地址未配置:请检查 VITE_AUTH_BASE 或 VITE_API_BASE_URL");
|
||||
}
|
||||
guardAuthorizeLoop();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
sessionStorage.setItem("pkce_verifier", verifier);
|
||||
const state = randomString(24);
|
||||
sessionStorage.setItem("oidc_state", state);
|
||||
|
||||
const authorizeURL = `${AUTH_BASE}/authorize?${new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "openid profile tenant",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString()}`;
|
||||
|
||||
// redirect 参数由认证中心按 base64.RawURLEncoding 解析(无 = 填充)
|
||||
const redirectParam = base64url(new TextEncoder().encode(authorizeURL));
|
||||
window.location.href =
|
||||
`${AUTH_BASE}/login?client_id=${encodeURIComponent(CLIENT_ID)}` +
|
||||
`&redirect=${redirectParam}`;
|
||||
}
|
||||
|
||||
/** 处理认证中心回跳:用 code 换令牌 */
|
||||
export async function handleAuthorizeCallback(query = {}) {
|
||||
// 交接页加载期间,启动期请求是用旧 token 发出的。先清掉旧登录态,
|
||||
// 避免并发 401 续期拿旧 refresh_token 触发「重放吊销」把新会话一起废掉,
|
||||
// 也避免旧 userInfo 被误当成新会话的用户信息。
|
||||
clearTokens();
|
||||
localStorage.removeItem("userInfo");
|
||||
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
const savedState = sessionStorage.getItem("oidc_state");
|
||||
const verifier = sessionStorage.getItem("pkce_verifier");
|
||||
|
||||
if (!code) throw new Error("缺少授权码");
|
||||
if (savedState && state !== savedState) throw new Error("state 校验失败");
|
||||
if (!verifier) throw new Error("PKCE 校验信息丢失,请重新登录");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
});
|
||||
|
||||
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) {
|
||||
throw new Error(data.error_description || data.error || "换取令牌失败");
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("pkce_verifier");
|
||||
sessionStorage.removeItem("oidc_state");
|
||||
setTokens(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 刷新中的令牌请求(single-flight):并发 401 时共用同一次刷新。
|
||||
// 认证中心的 refresh_token 是一次性轮换的,同一个 refresh_token 被使用两次会被
|
||||
// 判定为重放攻击并吊销整个会话(见 services/auth/token.go 的 RefreshTokens),
|
||||
// 因此这里必须保证「任一时刻只有一个刷新请求在飞」。
|
||||
let refreshingPromise = null;
|
||||
|
||||
/** 静默刷新访问令牌(带并发去重) */
|
||||
export async function refreshAccessToken() {
|
||||
if (refreshingPromise) return refreshingPromise;
|
||||
refreshingPromise = doRefreshAccessToken().finally(() => {
|
||||
refreshingPromise = null;
|
||||
});
|
||||
return refreshingPromise;
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken() {
|
||||
const refresh = getRefreshToken();
|
||||
if (!refresh) throw new Error("无刷新令牌");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refresh,
|
||||
client_id: CLIENT_ID,
|
||||
});
|
||||
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) {
|
||||
throw new Error(data.error_description || data.error || "刷新令牌失败");
|
||||
}
|
||||
setTokens(data);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** 单点登出:吊销令牌后回认证中心登出页 */
|
||||
export async function logoutSSO() {
|
||||
if (!AUTH_BASE) return;
|
||||
const token = getAccessToken();
|
||||
try {
|
||||
await fetch(`${AUTH_BASE}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token, refresh_token: getRefreshToken() }),
|
||||
});
|
||||
} catch (e) {
|
||||
// 吊销失败也要继续登出,避免卡在本端
|
||||
}
|
||||
clearTokens();
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
post_logout_redirect_uri: POST_LOGOUT_URI,
|
||||
});
|
||||
window.location.href = `${AUTH_BASE}/logout?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「当前登录的那一家企业」的名称。
|
||||
*
|
||||
* userinfo 的 tenants 是该账号可进入的全部企业,只能用于做企业切换列表;
|
||||
* 顶部展示必须是当前会话选中的企业(tid),不能把全部企业名拼接。
|
||||
* 取值顺序:后端 tenant_name → 按 tid 在 tenants 中匹配 → 兜底第一家。
|
||||
*/
|
||||
export function resolveTenantName(data) {
|
||||
if (!data) return '';
|
||||
if (data.tenant_name) return data.tenant_name;
|
||||
const list = data.tenants || [];
|
||||
const hit = list.find((t) => String(t.tid) === String(data.tid));
|
||||
if (hit) return hit.tenant_name || '';
|
||||
return list.length ? (list[0].tenant_name || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保本地存在完整的用户信息(含 id / group_id)。
|
||||
*
|
||||
* 场景:统一认证上线或 ID 全量迁移后,浏览器里可能残留旧会话——
|
||||
* 只有 token 没有 userInfo,或 userInfo 缺少 id,菜单等接口会报
|
||||
*「用户ID不存在」。这里主动补全;若令牌已失效则清空登录态返回 null,
|
||||
* 由调用方跳转登录。
|
||||
*/
|
||||
export async function ensureUserInfo() {
|
||||
let info = null;
|
||||
try {
|
||||
const raw = localStorage.getItem('userInfo');
|
||||
info = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
info = null;
|
||||
}
|
||||
if (info && info.id) {
|
||||
return info;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchUserInfo();
|
||||
const normalized = {
|
||||
id: data.id || data.sub || '',
|
||||
account: data.account || data.mobile || '',
|
||||
name: data.name || data.nickname || '',
|
||||
group_id: data.group_id || '',
|
||||
tid: data.tid || '',
|
||||
tenant_name: resolveTenantName(data),
|
||||
avatar: data.avatar || '',
|
||||
type: 'backend'
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
// 只有确认令牌失效(401)才清空登录态。userinfo 接口网络抖动 / 404 / 5xx
|
||||
// 属于临时故障,一律清 token 会把刚登录成功的用户反复送回认证中心。
|
||||
if (e?.status === 401 || e?.isAuthError) {
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
return null;
|
||||
}
|
||||
console.warn('[auth] 获取用户信息失败,保留当前登录态:', e);
|
||||
return info && info.id ? info : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取当前登录用户(认证中心视角) */
|
||||
export async function fetchUserInfo() {
|
||||
let res = await fetch(`${AUTH_BASE}/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// 访问令牌可能刚过期:先静默续期再重试一次,
|
||||
// 否则调用方(路由守卫的 ensureUserInfo)会把用户直接踢回认证中心登录
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
res = await fetch(`${AUTH_BASE}/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${newToken}` },
|
||||
});
|
||||
} catch (e) {
|
||||
// 续期失败:保持原响应,交给调用方按「未登录」处理
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(`获取用户信息失败(${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 切换企业(一人多企业场景) */
|
||||
export async function switchTenant(tid) {
|
||||
const res = await fetch(`${AUTH_BASE}/switch-tenant`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
body: JSON.stringify({ tid, client_id: CLIENT_ID }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.code !== 200) {
|
||||
const msg = data.msg || "切换企业失败";
|
||||
// 会话被吊销/过期:本端已无法继续,清掉登录态回认证中心重新登录,
|
||||
// 否则用户会卡在「点了没反应」的状态
|
||||
if (res.status === 401 || msg.includes("会话") || msg.includes("重新登录")) {
|
||||
clearTokens();
|
||||
localStorage.removeItem("userInfo");
|
||||
redirectToAuthorize().catch((err) => console.error("跳转统一认证失败:", err));
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (data.data?.tokens) setTokens(data.data.tokens);
|
||||
// 切换后同步本地缓存的企业信息,避免顶部仍显示切换前的企业名
|
||||
try {
|
||||
const info = await fetchUserInfo();
|
||||
const raw = localStorage.getItem('userInfo');
|
||||
const merged = raw ? JSON.parse(raw) : {};
|
||||
merged.tid = info.tid || tid;
|
||||
merged.tenant_name = resolveTenantName(info);
|
||||
localStorage.setItem('userInfo', JSON.stringify(merged));
|
||||
} catch (e) {
|
||||
// 同步失败不阻断切换结果
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 「复制修改」通用字段处理。
|
||||
*
|
||||
* 复制 = 用已有记录把表单预填好,把「同租户内不能重复」的字段改掉,
|
||||
* 让用户只改几项就能保存成一条新记录。
|
||||
*
|
||||
* 各模块的复制差异都体现在调用处,这里只提供字段级的小工具。
|
||||
*/
|
||||
|
||||
/** 名称加「- 副本」后缀,一眼能看出这是复制出来的那条 */
|
||||
export function copyName(name) {
|
||||
const value = String(name || '').trim();
|
||||
return value ? `${value} - 副本` : '- 副本';
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码加「-COPY」后缀。
|
||||
*
|
||||
* 编码在同租户内唯一,复制时必须换一个新的。后缀只用字母与横线,
|
||||
* 以符合各处的编码校验 /^[a-zA-Z0-9_-]*$/。
|
||||
*
|
||||
* maxLength 传该字段的长度上限:加上后缀后超长会被前端的
|
||||
* maxlength 直接截断,所以这里先按上限裁剪原值再拼后缀。
|
||||
*/
|
||||
export function copyCode(code, maxLength = 50) {
|
||||
const value = String(code || '').trim();
|
||||
if (!value) return '';
|
||||
const suffix = '-COPY';
|
||||
const keep = Math.max(1, maxLength - suffix.length);
|
||||
return `${value.slice(0, keep)}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 统一日期时间格式化工具(backend 端)
|
||||
*
|
||||
* 后端(Go)返回的时间字段为 RFC3339 / ISO 8601 字符串,
|
||||
* 例如 "2026-03-31T23:34:07+08:00",直接渲染不友好。
|
||||
* 页面上显示时间统一使用本工具,输出:
|
||||
* formatDateTime / formatTime -> 2026-03-31 23:34:07(YYYY-MM-DD HH:mm:ss)
|
||||
* formatDate / formatDateOnly -> 2026-03-31(YYYY-MM-DD)
|
||||
*/
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function toDate(value: any): Date | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
|
||||
// 兼容秒级 / 毫秒级时间戳
|
||||
if (typeof value === "number") {
|
||||
const d = new Date(value < 1e12 ? value * 1000 : value);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
// "2026-03-31 23:34:07" 形式在部分浏览器解析异常,统一先替换为 ISO 形式
|
||||
const str = String(value).trim().replace(" ", "T");
|
||||
const d = new Date(str);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** 2026-03-31 23:34:07(YYYY-MM-DD HH:mm:ss);非法值返回 "-" */
|
||||
export function formatDateTime(value: any): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return "-";
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** 与 formatDateTime 相同(兼容旧调用名) */
|
||||
export function formatTime(value: any): string {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
/** 2026-03-31(YYYY-MM-DD);非法值返回 "-";纯日期串不做时区换算 */
|
||||
export function formatDate(value: any): string {
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
|
||||
return value.trim();
|
||||
}
|
||||
const d = toDate(value);
|
||||
if (!d) return "-";
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 与 formatDate 相同(兼容旧调用名) */
|
||||
export function formatDateOnly(value: any): string {
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 浏览器标签页图标(favicon)。
|
||||
*
|
||||
* 图标来源:平台「系统设置 - 平台设置 - 平台信息」的「站点图标」(标签 ico)。
|
||||
* 未配置时保留 index.html 里的默认图标,不做任何改写。
|
||||
*/
|
||||
|
||||
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
|
||||
/** 相对路径(/uploads/x.ico)补上接口基址,完整 URL 原样使用 */
|
||||
function resolveIconUrl(url) {
|
||||
const value = String(url || "").trim().replace(/\\/g, "/");
|
||||
if (!value) return "";
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value)) return value;
|
||||
return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把指定地址应用为页面 favicon。
|
||||
* @param {string} url 图片地址(可相对)
|
||||
* @returns {boolean} 是否已应用
|
||||
*/
|
||||
export function applyFavicon(url) {
|
||||
const href = resolveIconUrl(url);
|
||||
if (!href) return false;
|
||||
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.setAttribute("rel", "icon");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// .ico 显式声明 MIME,其余交给浏览器按扩展名/内容识别
|
||||
if (/\.ico(\?|#|$)/i.test(href)) {
|
||||
link.setAttribute("type", "image/x-icon");
|
||||
} else {
|
||||
link.removeAttribute("type");
|
||||
}
|
||||
link.setAttribute("href", href);
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { refreshAccessToken, clearTokens, redirectToAuthorize, isAuthHandoff } from '@/utils/authClient';
|
||||
|
||||
// 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环
|
||||
let ssoRedirecting = false;
|
||||
|
||||
// 获取API基础URL,添加调试信息
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -9,7 +13,79 @@ const service = axios.create({
|
||||
timeout: 300000,
|
||||
withCredentials: false // JWT 不需要 Cookie
|
||||
});
|
||||
|
||||
|
||||
// 构造鉴权失败错误:带上 status,调用方(如菜单 store)可直接按 401 判断,
|
||||
// 不必依赖中文文案;否则文案一改,登录态判断就静默失效了。
|
||||
function authError(message) {
|
||||
const err = new Error(message || '登录状态已失效,请重新登录');
|
||||
err.status = 401;
|
||||
err.isAuthError = true;
|
||||
return err;
|
||||
}
|
||||
|
||||
// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转)
|
||||
function redirectToLoginOnce() {
|
||||
// 交接页里的 401 来自旧 token,不代表刚换到的新登录态失效。
|
||||
// 在这里清 token / 跳认证中心会造成「登录成功后又回登录页」。
|
||||
if (isAuthHandoff()) return;
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
if (ssoRedirecting) return;
|
||||
ssoRedirecting = true;
|
||||
redirectToAuthorize().catch((err) => {
|
||||
console.error('跳转统一认证失败:', err);
|
||||
ssoRedirecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 401 统一处理:
|
||||
// 阶段一:并发场景下,本请求带的是旧令牌,而别的请求已换成新令牌 → 直接用最新令牌重放;
|
||||
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次;
|
||||
// 阶段三:续期失败 / 续期后仍 401 → 清登录态回认证中心。
|
||||
// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次;
|
||||
// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
|
||||
async function recoverAuth(config, bizMsg) {
|
||||
// 交接页的 401 来自旧 token,不能用旧 refresh_token 续期(会触发重放吊销),
|
||||
// 也不能重放或自动跳转;换码完成后整页重载即可恢复正常请求。
|
||||
if (isAuthHandoff()) return Promise.reject(authError('正在完成登录,请稍候'));
|
||||
const latestToken = localStorage.getItem('token') || '';
|
||||
const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
|
||||
if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
|
||||
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', config?.url);
|
||||
config.headers['Authorization'] = `Bearer ${latestToken}`;
|
||||
config._replayedWithLatest = true;
|
||||
return service.request(config);
|
||||
}
|
||||
|
||||
if (config?._refreshed) {
|
||||
// 续期拿到的令牌仍被判定过期/无效:继续留在页面只会一路报错,直接回认证中心
|
||||
redirectToLoginOnce();
|
||||
return Promise.reject(authError(bizMsg));
|
||||
}
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
config._refreshed = true;
|
||||
return service.request(config);
|
||||
} catch (e) {
|
||||
redirectToLoginOnce();
|
||||
return Promise.reject(authError('登录状态已失效,请重新登录'));
|
||||
}
|
||||
}
|
||||
|
||||
// 令牌类错误提示。后端部分接口(全局鉴权中间件为 warn 模式时控制器自行鉴权)
|
||||
// 会在 HTTP 200 的 body 里返回 code:401,需要按提示词识别。
|
||||
// 不能把所有 body code:401 都当令牌问题:个别接口(如新增用户时校验原密码)
|
||||
// 也用 401 表示业务失败,那类响应重放没有意义,更不能因此把用户登出。
|
||||
const TOKEN_ERROR_HINTS = ['token已过期', '无效的token', '未提供认证信息', '认证信息格式错误'];
|
||||
|
||||
function isTokenFailure(body) {
|
||||
if (!body || Number(body.code) !== 401) return false;
|
||||
const msg = String(body.msg || body.message || '');
|
||||
return TOKEN_ERROR_HINTS.some((hint) => msg.includes(hint));
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
@@ -34,29 +110,34 @@ service.interceptors.request.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
const data = response.data;
|
||||
// 兜住 HTTP 200 + body code:401 的令牌失效响应,让它走与真实 401 相同的续期流程
|
||||
if (isTokenFailure(data)) {
|
||||
return recoverAuth(response.config, data.msg || data.message);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
error => {
|
||||
async error => {
|
||||
if (error.response) {
|
||||
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
||||
// 避免页面只显示 "Request failed with status code xxx"
|
||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
console.error('未授权,请重新登录');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
if (window.location.hash !== '#/login') {
|
||||
window.location.href = '#/login';
|
||||
}
|
||||
return Promise.reject(new Error('token无效'));
|
||||
return recoverAuth(error.config, bizMsg);
|
||||
case 404:
|
||||
console.error('请求的资源不存在');
|
||||
break;
|
||||
default:
|
||||
console.error('请求失败,请稍后再试');
|
||||
}
|
||||
if (bizMsg) {
|
||||
return Promise.reject(new Error(bizMsg));
|
||||
}
|
||||
} else if (error.request) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
console.error('请求超时,请稍后重试');
|
||||
@@ -69,5 +150,5 @@ service.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default service;
|
||||
|
||||
export default service;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 全站主题模式:跟随系统 / 亮色 / 暗色。
|
||||
*
|
||||
* 顶栏个人中心菜单与门户页共用这一份状态,避免出现两处各自维护
|
||||
* 「是否暗色」而互相覆盖的情况。
|
||||
*/
|
||||
|
||||
const MODE_KEY = "yz-theme-mode";
|
||||
// 兼容旧版本:此前只保存最终的亮/暗结果
|
||||
const LEGACY_KEY = "element-plus-theme";
|
||||
|
||||
export const THEME_OPTIONS = [
|
||||
{ value: "system", label: "跟随系统" },
|
||||
{ value: "light", label: "亮色" },
|
||||
{ value: "dark", label: "暗色" },
|
||||
];
|
||||
|
||||
export function getThemeMode() {
|
||||
try {
|
||||
const mode = localStorage.getItem(MODE_KEY);
|
||||
if (["system", "light", "dark"].includes(mode)) return mode;
|
||||
const legacy = localStorage.getItem(LEGACY_KEY);
|
||||
if (legacy === "dark" || legacy === "light") return legacy;
|
||||
} catch (e) {
|
||||
// localStorage 不可用时退化为跟随系统
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function isDarkByMode(mode) {
|
||||
if (mode === "dark") return true;
|
||||
if (mode === "light") return false;
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 应用主题并返回当前是否为暗色 */
|
||||
export function applyThemeMode(mode) {
|
||||
const dark = isDarkByMode(mode);
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, mode);
|
||||
localStorage.setItem(LEGACY_KEY, dark ? "dark" : "light");
|
||||
} catch (e) {
|
||||
// 存储失败不影响本次主题生效
|
||||
}
|
||||
return dark;
|
||||
}
|
||||
|
||||
/** 监听系统配色变化,返回取消监听函数 */
|
||||
export function onSystemThemeChange(handler) {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = () => handler(query.matches);
|
||||
if (query.addEventListener) {
|
||||
query.addEventListener("change", listener);
|
||||
return () => query.removeEventListener("change", listener);
|
||||
}
|
||||
query.addListener(listener);
|
||||
return () => query.removeListener(listener);
|
||||
}
|
||||
+431
-51
@@ -1,93 +1,473 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="not-found-big">404</div>
|
||||
<div class="not-found-message">页面未找到</div>
|
||||
<div class="not-found-desc">很抱歉,您访问的页面不存在或已被删除。</div>
|
||||
<a href="javascript:void(0)" class="back-home-btn" @click.prevent="backToHome">
|
||||
返回首页
|
||||
</a>
|
||||
<div class="not-found-page">
|
||||
<!-- 背景装饰:柔光 + 网格 -->
|
||||
<div class="bg-decor" aria-hidden="true">
|
||||
<span class="orb orb-1"></span>
|
||||
<span class="orb orb-2"></span>
|
||||
<span class="grid"></span>
|
||||
</div>
|
||||
|
||||
<div class="not-found-card">
|
||||
<div class="visual">
|
||||
<p class="code">404</p>
|
||||
<svg class="illustration" viewBox="0 0 360 240" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="nf-blue" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#5b8cff" />
|
||||
<stop offset="100%" stop-color="#2f6bff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="nf-paper" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="#eef4ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<ellipse cx="180" cy="214" rx="104" ry="12" fill="#2f6bff" opacity=".08" />
|
||||
|
||||
<g transform="rotate(-6 180 118)">
|
||||
<rect x="96" y="40" width="168" height="152" rx="16" fill="url(#nf-paper)" stroke="#d8e3f8" />
|
||||
<rect x="120" y="68" width="86" height="10" rx="5" fill="#dce7fb" />
|
||||
<rect x="120" y="90" width="120" height="10" rx="5" fill="#e9f0fd" />
|
||||
<rect x="120" y="112" width="70" height="10" rx="5" fill="#e9f0fd" />
|
||||
<rect
|
||||
x="120"
|
||||
y="140"
|
||||
width="120"
|
||||
height="26"
|
||||
rx="8"
|
||||
fill="none"
|
||||
stroke="#b9cdf4"
|
||||
stroke-dasharray="6 5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(214 94)">
|
||||
<circle cx="34" cy="34" r="34" fill="#ffffff" fill-opacity=".92" stroke="url(#nf-blue)" stroke-width="6" />
|
||||
<path d="M58 58L86 86" stroke="url(#nf-blue)" stroke-width="10" stroke-linecap="round" />
|
||||
<text
|
||||
x="34"
|
||||
y="46"
|
||||
text-anchor="middle"
|
||||
font-size="34"
|
||||
font-weight="700"
|
||||
fill="#2f6bff"
|
||||
font-family="inherit"
|
||||
>
|
||||
?
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<circle cx="74" cy="70" r="6" fill="#7aa2ff" opacity=".7" />
|
||||
<circle cx="306" cy="158" r="9" fill="#9dbcff" opacity=".5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p class="eyebrow"><i class="dot"></i>ERROR 404</p>
|
||||
<h1>页面走丢了</h1>
|
||||
<p class="desc">
|
||||
你访问的页面不存在、已被移除,或没有访问权限。<br />
|
||||
请检查链接是否正确,或返回首页继续操作。
|
||||
</p>
|
||||
<p v-if="currentPath" class="path" :title="currentPath">{{ currentPath }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn ghost" @click="backToPrev">返回上一页</button>
|
||||
<button type="button" class="btn primary" @click="backToHome">返回首页</button>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
<button type="button" class="link" @click="clearCache">清除本地缓存</button>
|
||||
<span class="sep">·</span>
|
||||
若是系统内部链接失效,请联系管理员
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 当前访问路径:方便用户/管理员定位是哪个链接失效
|
||||
const currentPath = computed(() => {
|
||||
const full = route.fullPath || "";
|
||||
if (!full || full === "/") return "";
|
||||
return full.length > 60 ? `${full.slice(0, 60)}…` : full;
|
||||
});
|
||||
|
||||
const backToHome = () => {
|
||||
// 使用 /home 作为首页(Hash模式下的系统导航页)
|
||||
// Hash 模式下 /home 是系统导航门户
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
const backToPrev = () => {
|
||||
// 有站内历史就退一步,否则回首页
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
backToHome();
|
||||
};
|
||||
|
||||
const clearAllCookies = () => {
|
||||
const cookies = document.cookie ? document.cookie.split(";") : [];
|
||||
for (const cookie of cookies) {
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name = eqPos > -1 ? cookie.slice(0, eqPos).trim() : cookie.trim();
|
||||
if (!name) continue;
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${location.hostname}`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要清除本地缓存和 Cookie 吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
clearAllCookies();
|
||||
ElMessage.success("缓存已清除");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// 用户取消,不做任何操作
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
.not-found-page {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(120deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(900px 480px at 10% 6%, #e9f0ff 0%, rgba(233, 240, 255, 0) 62%),
|
||||
radial-gradient(760px 420px at 92% 96%, #eef4ff 0%, rgba(238, 244, 255, 0) 64%),
|
||||
#f6f9ff;
|
||||
color: #24304a;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
background: #fff;
|
||||
padding: 60px 48px 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
animation: float-in 0.8s cubic-bezier(0.7, 0.13, 0.45, 0.81) both;
|
||||
.bg-decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.not-found-big {
|
||||
font-size: 100px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
left: -110px;
|
||||
top: -120px;
|
||||
background: radial-gradient(circle at 35% 35%, #cfdcff 0%, rgba(207, 220, 255, 0) 70%);
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
right: -150px;
|
||||
bottom: -170px;
|
||||
background: radial-gradient(circle at 60% 40%, #d9e4ff 0%, rgba(217, 228, 255, 0) 70%);
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(47, 107, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(47, 107, 255, 0.07) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, #000 28%, transparent 76%);
|
||||
mask-image: radial-gradient(ellipse at center, #000 28%, transparent 76%);
|
||||
}
|
||||
|
||||
.not-found-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 380px) minmax(300px, 400px);
|
||||
align-items: center;
|
||||
gap: 8px 32px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 36px 44px;
|
||||
border: 1px solid #e6eefc;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 26px 60px -30px rgba(37, 74, 161, 0.3),
|
||||
0 2px 10px rgba(37, 74, 161, 0.05);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: nf-rise 0.5s cubic-bezier(0.22, 0.68, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 84px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(135deg, #6f97ff 0%, #2f6bff 58%, #1d4ed8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
color: #2f6bff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #2f6bff;
|
||||
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.16);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 30px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: #1b2740;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #66738c;
|
||||
}
|
||||
|
||||
.path {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin: 0 0 22px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f2f6ff;
|
||||
border: 1px dashed #c9d9f7;
|
||||
color: #5a6a88;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.not-found-message {
|
||||
font-size: 28px;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin-bottom: 28px;
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.back-home-btn {
|
||||
display: inline-block;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.btn.primary {
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 10px 36px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(252, 92, 125, 0.07);
|
||||
background: linear-gradient(135deg, #4d82ff 0%, #2f6bff 100%);
|
||||
box-shadow: 0 10px 22px -10px rgba(47, 107, 255, 0.7);
|
||||
}
|
||||
|
||||
.back-home-btn:hover {
|
||||
background: linear-gradient(-90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 14px 26px -12px rgba(47, 107, 255, 0.8);
|
||||
background: linear-gradient(135deg, #5c8dff 0%, #3a74ff 100%);
|
||||
}
|
||||
|
||||
@keyframes float-in {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.97);
|
||||
.btn.ghost {
|
||||
border: 1px solid #d7e2f6;
|
||||
background: #fff;
|
||||
color: #40506e;
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
border-color: #b9cdf4;
|
||||
background: #f7faff;
|
||||
color: #2f6bff;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #93a0b8;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6b8fd9;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #2f6bff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
@keyframes nf-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.not-found-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 28px 24px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footnote,
|
||||
.path {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题(backend 支持亮/暗切换;platform 无暗色时不会命中) */
|
||||
html.dark .not-found-page {
|
||||
background:
|
||||
radial-gradient(900px 480px at 10% 6%, rgba(47, 107, 255, 0.18) 0%, rgba(47, 107, 255, 0) 62%),
|
||||
radial-gradient(760px 420px at 92% 96%, rgba(84, 122, 214, 0.14) 0%, rgba(84, 122, 214, 0) 64%),
|
||||
#10151f;
|
||||
color: #e5ebf7;
|
||||
}
|
||||
|
||||
html.dark .not-found-card {
|
||||
border-color: #263049;
|
||||
background: rgba(24, 31, 46, 0.9);
|
||||
box-shadow: 0 26px 60px -30px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
html.dark .eyebrow {
|
||||
background: rgba(47, 107, 255, 0.16);
|
||||
color: #8fb0ff;
|
||||
}
|
||||
|
||||
html.dark h1 {
|
||||
color: #eef2fb;
|
||||
}
|
||||
|
||||
html.dark .desc {
|
||||
color: #9aa7c2;
|
||||
}
|
||||
|
||||
html.dark .path {
|
||||
border-color: #33415e;
|
||||
background: #1a2233;
|
||||
color: #9fb0cd;
|
||||
}
|
||||
|
||||
html.dark .btn.ghost {
|
||||
border-color: #303c56;
|
||||
background: #1c2434;
|
||||
color: #cfdaf0;
|
||||
}
|
||||
|
||||
html.dark .btn.ghost:hover {
|
||||
border-color: #3f5580;
|
||||
background: #212b3f;
|
||||
color: #8fb0ff;
|
||||
}
|
||||
|
||||
html.dark .footnote {
|
||||
color: #7d8aa6;
|
||||
}
|
||||
|
||||
html.dark .link {
|
||||
color: #7fa0e0;
|
||||
}
|
||||
|
||||
html.dark .link:hover {
|
||||
color: #a9c4ff;
|
||||
}
|
||||
</style>
|
||||
+155
-21
@@ -2,15 +2,40 @@
|
||||
import CommonAside from '@/components/CommonAside.vue';
|
||||
import CommonHeader from '@/components/CommonHeader.vue';
|
||||
import { useTabsStore } from '@/stores';
|
||||
import { useDictStore } from '@/stores/dict';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ref, watch, reactive, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminNavigation } from '@/composables/useAdminNavigation';
|
||||
|
||||
const tabsStore = useTabsStore();
|
||||
const dictStore = useDictStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const defaultDashboardPath = '/home';
|
||||
const { defaultTab } = useAdminNavigation();
|
||||
|
||||
// 「首页」已移除:默认标签 = 当前模块的仪表盘,随模块切换自动更新
|
||||
const defaultDashboardPath = computed(
|
||||
() => tabsStore.defaultDashboardPath || route.fullPath
|
||||
);
|
||||
|
||||
let lastDefaultPath = '';
|
||||
watch(
|
||||
defaultTab,
|
||||
(tab) => {
|
||||
const path = tab?.path || '';
|
||||
if (!path || path === lastDefaultPath) return;
|
||||
lastDefaultPath = path;
|
||||
tabsStore.setDefaultTab({
|
||||
title: tab.title,
|
||||
fullPath: path,
|
||||
name: tab.title,
|
||||
icon: tab.icon,
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// ========== 手机端侧栏抽屉控制 ==========
|
||||
const MOBILE_QUERY = '(max-width: 768px)';
|
||||
@@ -76,6 +101,9 @@ function restoreTabFromRoute() {
|
||||
|
||||
// 组件挂载后,根据当前路由恢复 tab
|
||||
onMounted(() => {
|
||||
// 进入后台后把库里所有字典预热到缓存(失败不影响按需加载)
|
||||
dictStore.loadAll();
|
||||
|
||||
// 等待路由完全加载后再恢复
|
||||
nextTick(() => {
|
||||
// 刷新时,localStorage 中已保存的 tabs 会在 store 初始化时恢复
|
||||
@@ -139,9 +167,9 @@ watch(
|
||||
const handleAsideMenuClick = async (menuItem) => {
|
||||
const targetPath = menuItem.path;
|
||||
|
||||
if (targetPath === '/home') {
|
||||
if (targetPath === '/home' || targetPath === '/') {
|
||||
tabsStore.closeAll();
|
||||
router.push('/home');
|
||||
router.push(defaultDashboardPath.value || '/');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,7 +226,7 @@ const closeOthers = () => {
|
||||
};
|
||||
const closeAll = () => {
|
||||
tabsStore.closeAll();
|
||||
router.push(defaultDashboardPath);
|
||||
router.push(defaultDashboardPath.value || route.fullPath);
|
||||
};
|
||||
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
|
||||
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
|
||||
@@ -208,9 +236,9 @@ watch(
|
||||
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
|
||||
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
|
||||
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
|
||||
// 如果切换到首页,关闭其他所有tab
|
||||
if (newVal === '/home') {
|
||||
tabsStore.closeAll();
|
||||
// 切回默认仪表盘时,清掉其它标签页
|
||||
if (newVal === defaultDashboardPath.value) {
|
||||
tabsStore.closeOthers();
|
||||
}
|
||||
|
||||
// 检查路由是否存在
|
||||
@@ -386,7 +414,7 @@ function hideContextMenu() {
|
||||
contextMenu.tab = null;
|
||||
}
|
||||
function closeTabContextTab() {
|
||||
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) {
|
||||
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath.value) {
|
||||
tabsStore.removeTab(contextMenu.tab.fullPath);
|
||||
}
|
||||
hideContextMenu();
|
||||
@@ -431,15 +459,15 @@ function closeOthersContextTab() {
|
||||
function closeAllTabs() {
|
||||
tabsStore.closeAll();
|
||||
hideContextMenu();
|
||||
router.push(defaultDashboardPath);
|
||||
router.push(defaultDashboardPath.value || route.fullPath);
|
||||
}
|
||||
|
||||
// 计算是否可以关闭左侧/右侧
|
||||
const canCloseLeft = computed(() => {
|
||||
if (!contextMenu.tab) return false;
|
||||
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
|
||||
// 至少左侧有一个可关闭的tab(排除首页)
|
||||
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath);
|
||||
// 至少左侧有一个可关闭的tab(排除默认仪表盘)
|
||||
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath.value);
|
||||
});
|
||||
|
||||
const canCloseRight = computed(() => {
|
||||
@@ -473,16 +501,17 @@ const canCloseRight = computed(() => {
|
||||
></div>
|
||||
</transition>
|
||||
|
||||
<el-container class="main-container">
|
||||
<common-aside
|
||||
class="common-aside-wrapper"
|
||||
:class="{ 'mobile-open': sidebarVisible }"
|
||||
@menu-click="handleAsideMenuClick"
|
||||
/>
|
||||
<el-container>
|
||||
<el-header class="main-header">
|
||||
<common-header />
|
||||
</el-header>
|
||||
<el-container class="main-container" direction="vertical">
|
||||
<!-- 顶栏全宽:logo 位于页面真正的左上角 -->
|
||||
<el-header class="main-header">
|
||||
<common-header @menu-click="handleAsideMenuClick" />
|
||||
</el-header>
|
||||
<el-container class="main-body">
|
||||
<common-aside
|
||||
class="common-aside-wrapper"
|
||||
:class="{ 'mobile-open': sidebarVisible }"
|
||||
@menu-click="handleAsideMenuClick"
|
||||
/>
|
||||
<el-main class="right-main">
|
||||
<div class="multi-tabs-wrapper">
|
||||
<el-tabs
|
||||
@@ -882,3 +911,108 @@ const canCloseRight = computed(() => {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 浅色框架:白顶栏 + 浅蓝内容区 -->
|
||||
<style scoped lang="less">
|
||||
.common-layout,
|
||||
.main-container {
|
||||
background-color: var(--yz-page-bg) !important;
|
||||
}
|
||||
|
||||
// 顶栏全宽 + 下方「侧栏 / 内容」两栏,左侧保持一整块白色面板
|
||||
.main-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
flex: 0 0 var(--yz-header-height);
|
||||
width: 100%;
|
||||
height: var(--yz-header-height) !important;
|
||||
background-color: var(--yz-header-bg) !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.main-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background-color: var(--yz-page-bg);
|
||||
}
|
||||
|
||||
:deep(.el-aside) {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.right-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
background-color: var(--yz-page-bg) !important;
|
||||
}
|
||||
|
||||
.multi-tabs-wrapper {
|
||||
margin-bottom: 14px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--yz-border) !important;
|
||||
border-radius: 12px;
|
||||
background: var(--yz-card-bg) !important;
|
||||
box-shadow: var(--yz-shadow) !important;
|
||||
}
|
||||
|
||||
.multi-tabs {
|
||||
:deep(.el-tabs__item) {
|
||||
border: 1px solid transparent !important;
|
||||
background: transparent !important;
|
||||
color: var(--yz-text-regular) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item:hover) {
|
||||
border-color: transparent !important;
|
||||
background: var(--yz-primary-softer) !important;
|
||||
color: var(--yz-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
border-color: var(--el-color-primary-light-7) !important;
|
||||
background: var(--yz-primary-soft) !important;
|
||||
color: var(--yz-primary) !important;
|
||||
font-weight: 600;
|
||||
|
||||
.el-icon-close {
|
||||
color: var(--yz-primary) !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--yz-primary) !important;
|
||||
background-color: var(--el-color-primary-light-7) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
top: 12px;
|
||||
background: var(--yz-primary) !important;
|
||||
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.right-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
// 抽屉从全宽顶栏下方展开,不遮住 logo
|
||||
.common-layout .common-aside-wrapper {
|
||||
top: var(--yz-header-height) !important;
|
||||
bottom: 0 !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -185,6 +185,13 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题:卡片数字用纯白(默认 --el-text-color-primary 在暗色下是偏蓝灰的 #E5EAF3)
|
||||
html.dark & {
|
||||
.data-overview .data-card .card-content .text-box .value {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深度修改 Element Plus 卡片头部样式
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# MCP 工具中心(AI 模块)
|
||||
|
||||
本模块为「AI 智能体」接入 **MCP(Model Context Protocol)** 工具调用能力:
|
||||
- **MCP 市场**:内置一批可一键添加的服务,以及项目自带的「内置演示 MCP」(零配置,直接可用)。
|
||||
- **选择即用**:在「我的服务」里通过「启用」开关控制哪些 MCP 在会话中被调用;**只有启用状态的服务** 才会注入到 AI 会话。
|
||||
- **会话自动调用**:聊天时,AI 会自动决定是否调用已启用的 MCP 工具,前端实时展示工具调用卡片(参数 / 结果 / 状态)。
|
||||
|
||||
---
|
||||
|
||||
## 一、如何使用
|
||||
|
||||
1. 打开「AI 广场」→ 左侧边栏点击 **CPU(MCP 工具中心)** 按钮。
|
||||
2. 「MCP 市场」页 → 点击「内置演示 MCP」的 **添加**(或添加天眼查/企查查等,需自行申请对应的 URL/Key)。
|
||||
3. 切到「我的服务」页 → 把要用的服务 **启用** 开关打开;可点击「测试」验证连接并预览工具。
|
||||
4. 回到聊天窗 → 提问。例如:
|
||||
- `现在几点钟了?`(触发 `get_time`)
|
||||
- `帮我计算 (3+5)*2`(触发 `calculate`)
|
||||
- `连云港今天天气怎么样?`(触发 `weather_query`)
|
||||
5. 回答过程中,AI 气泡内会出现工具调用卡片:展开可看参数与返回结果。
|
||||
|
||||
> 头部「MCP」绿色指示器表示当前会话已接入 N 个 MCP 工具。
|
||||
|
||||
## 二、手动新增 MCP 服务
|
||||
|
||||
「我的服务」→「新增配置」,支持三种传输:
|
||||
|
||||
| 传输 | 说明 | 必填字段 |
|
||||
|---|---|---|
|
||||
| **stdio** | 本地进程(MCP Server 以子进程方式启动) | 启动命令 command;可选参数 args(JSON数组)、环境变量 env(JSON对象) |
|
||||
| **http** | Streamable HTTP 传输 | 服务地址 url;可选请求头 headers(JSON对象) |
|
||||
| **sse** | 传统 SSE 传输 | 服务地址 url;可选请求头 headers(JSON对象) |
|
||||
|
||||
示例(stdio + npx 远程包,Windows 会自动用 `cmd /c` 包装):
|
||||
```
|
||||
命令: npx -y @modelcontextprotocol/server-everything
|
||||
```
|
||||
示例(http):
|
||||
```
|
||||
地址: https://mcp.tianyancha.com/mcp
|
||||
请求头: {"Authorization":"Bearer 你的Key"}
|
||||
```
|
||||
|
||||
## 三、内置演示 MCP(cmd/demo-mcp)
|
||||
|
||||
项目自带一个 Go 编写的 stdio MCP Server,提供 5 个工具,用于零配置验证全链路:
|
||||
`get_time`(当前时间)、`calculate`(表达式计算)、`weather_query`(模拟天气)、`echo`、`get_user_profile`。
|
||||
|
||||
重新编译(如需):
|
||||
```bash
|
||||
cd go
|
||||
go build -o bin/demo-mcp.exe ./cmd/demo-mcp
|
||||
```
|
||||
|
||||
## 四、实现说明(文件清单)
|
||||
|
||||
### 后端(Go / Beego,`go/`)
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `models/backend_mcp_server.go` | MCP 服务器配置模型(传输、命令、URL、启用、状态、工具数) |
|
||||
| `controllers/backend_mcp_server.go` | MCP 服务器接口:市场 / 增删改查 / 测试 / 工具发现 / 启停 |
|
||||
| `services/mcp_client.go` | MCP 客户端管理器:stdio/http/sse 连接、工具列表、工具调用、连接缓存 |
|
||||
| `services/mcp_market.go` | MCP 市场目录(内置演示 + 天眼查/企查查/前瞻/IT桔子/BOSS直聘) |
|
||||
| `controllers/backend_ai_chat.go` | 聊天工具调用循环:注入已启用 MCP 工具 → LLM 返回 tool_calls → 执行 → 回填 → 流式输出 |
|
||||
| `cmd/demo-mcp/main.go` | 内置演示 MCP Server(stdio) |
|
||||
| `sql/create_backend_mcp_server.sql` | 建表 SQL(`yz_backend_mcp_server`) |
|
||||
|
||||
**SSE 事件扩展**(`POST /backend/ai/chat/send-stream`):
|
||||
`session` / `tool_list`(本次启用的工具)/ `tool_start`(开始调用某工具)/ `tool_result`(工具返回)/ `content`(文本增量)/ `done` / `error`
|
||||
|
||||
### 前端(Vue3 + Element Plus,`backend/src/`)
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `views/apps/ai/components/mcpConfig.vue` | MCP 工具中心(市场 + 我的服务 + 新增/编辑 + 工具预览) |
|
||||
| `views/apps/ai/components/chatWindow.vue` | 聊天窗:SSE 工具事件解析 + 工具调用卡片渲染 + MCP 状态指示 |
|
||||
| `views/apps/ai/components/chatSidebar.vue` | CPU 按钮入口(标题更新为「MCP 工具中心」) |
|
||||
| `api/ai.js` | MCP 服务器 / 市场 API 封装 |
|
||||
|
||||
### 模型要求
|
||||
- 需支持 **Function Calling / Tool Calling**(OpenAI 兼容与 Anthropic 均已适配)。
|
||||
- 已内置两种接入:OpenAI 兼容(含豆包/DeepSeek/OpenRouter 等)与 Anthropic。
|
||||
|
||||
## 五、依赖说明
|
||||
|
||||
- 新增 Go 依赖:`github.com/mark3labs/mcp-go v0.33.0`(MCP 官方协议的 Go 客户端/服务端库)。
|
||||
- `go.mod` 的 `go` 指令由 `1.17` 调整为 `1.23`(mcp-go v0.33 的最低要求;本机 Go 1.25 完全兼容)。
|
||||
- 数据库:需先执行 `sql/create_backend_mcp_server.sql`(本环境已执行建表)。
|
||||
|
||||
## 六、构建与运行
|
||||
|
||||
```bash
|
||||
# 后端(go/ 目录)
|
||||
go build -o main.exe .
|
||||
./main.exe # 监听 9000
|
||||
|
||||
# 前端(backend/ 目录,Vue SPA)
|
||||
npm run dev # 开发,vite 端口 4001,/api 代理到 9000
|
||||
npm run build # 生产构建
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="ai-playground-page">
|
||||
<el-empty description="AI 实验室(建设中)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 占位页面:AI 实验室
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-playground-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="chat-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<el-button type="primary" class="new-chat-btn" :icon="ChatDotRound" @click="handleNewChat">
|
||||
新建对话
|
||||
</el-button>
|
||||
<el-button :icon="Cpu" class="mcp-btn" @click="handleMcpConfig" title="MCP 工具中心" />
|
||||
<el-button :icon="Setting" class="settings-btn" @click="settingsVisible = true" title="AI接入设置" />
|
||||
</div>
|
||||
|
||||
<div class="session-list" v-loading="loading">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="session-item"
|
||||
:class="{ active: currentId === item.id }"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<el-icon class="session-icon"><ChatDotRound /></el-icon>
|
||||
<template v-if="editingId === item.id">
|
||||
<el-input
|
||||
v-model="editTitle"
|
||||
size="small"
|
||||
class="rename-input"
|
||||
@click.stop
|
||||
@keyup.enter="saveRename(item)"
|
||||
@keyup.esc="cancelRename"
|
||||
@blur="saveRename(item)"
|
||||
ref="renameInputRef"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="session-title" :title="item.title">{{ item.title }}</span>
|
||||
<div class="session-actions">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
class="action-btn"
|
||||
:icon="Edit"
|
||||
@click.stop="startRename(item)"
|
||||
title="重命名"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
class="action-btn"
|
||||
:icon="Delete"
|
||||
@click.stop="handleDelete(item)"
|
||||
title="删除"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && list.length === 0" description="暂无对话" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<ProviderDialog v-model="settingsVisible" @change="handleProviderChange" />
|
||||
<McpConfig v-model="mcpConfigVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ChatDotRound, Setting, Delete, Edit, Cpu } from '@element-plus/icons-vue'
|
||||
import { getChatSessionList, createChatSession, deleteChatSession, updateChatSession } from '@/api/ai'
|
||||
import ProviderDialog from './providerDialog.vue'
|
||||
import McpConfig from './mcpConfig.vue'
|
||||
|
||||
const emit = defineEmits(['select', 'new', 'rename'])
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const currentId = ref(null)
|
||||
const settingsVisible = ref(false)
|
||||
const mcpConfigVisible = ref(false)
|
||||
|
||||
// 重命名
|
||||
const editingId = ref(null)
|
||||
const editTitle = ref('')
|
||||
const renameInputRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getChatSessionList()
|
||||
if (res.code === 200) {
|
||||
list.value = res.data.list || []
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '查询失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
currentId.value = item.id
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
async function handleNewChat() {
|
||||
try {
|
||||
const res = await createChatSession({ title: '新对话' })
|
||||
if (res.code === 200) {
|
||||
const newSession = { id: res.data.id, title: res.data.title }
|
||||
list.value.unshift(newSession)
|
||||
currentId.value = newSession.id
|
||||
emit('new', newSession)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(item) {
|
||||
editingId.value = item.id
|
||||
editTitle.value = item.title
|
||||
nextTick(() => {
|
||||
if (renameInputRef.value) {
|
||||
const input = renameInputRef.value[0] || renameInputRef.value
|
||||
if (input && input.focus) input.focus()
|
||||
if (input && input.select) input.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId.value = null
|
||||
editTitle.value = ''
|
||||
}
|
||||
|
||||
async function saveRename(item) {
|
||||
if (editingId.value !== item.id) return
|
||||
const newTitle = editTitle.value.trim()
|
||||
if (!newTitle) {
|
||||
ElMessage.warning('名称不能为空')
|
||||
return
|
||||
}
|
||||
if (newTitle === item.title) {
|
||||
cancelRename()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateChatSession(item.id, { title: newTitle })
|
||||
item.title = newTitle
|
||||
ElMessage.success('重命名成功')
|
||||
// 如果是当前会话,通知父组件更新标题
|
||||
if (currentId.value === item.id) {
|
||||
emit('rename', { id: item.id, title: newTitle })
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '重命名失败')
|
||||
} finally {
|
||||
cancelRename()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除对话「${item.title}」吗?`, '提示', { type: 'warning' })
|
||||
await deleteChatSession(item.id)
|
||||
ElMessage.success('删除成功')
|
||||
list.value = list.value.filter(s => s.id !== item.id)
|
||||
if (currentId.value === item.id) {
|
||||
currentId.value = null
|
||||
emit('select', null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderChange() {
|
||||
// 配置变更后不需要特殊处理
|
||||
}
|
||||
|
||||
function handleMcpConfig() {
|
||||
mcpConfigVisible.value = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
fetchList,
|
||||
setCurrent: (id) => { currentId.value = id }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--el-fill-color-light);
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.new-chat-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mcp-btn {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.session-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.action-btn {
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user