Compare commits

48 Commits

Author SHA1 Message Date
dk
80f028bcb9 fix(工作报告): 修复工作报告存在的若干问题。
feat(加班申请): 支持批量审批。
2026-06-13 13:06:39 +08:00
5061eced32 refactor(projects): 登录页面重新设计 2026-06-12 22:42:23 +08:00
6896a86130 feat(projects): 工作台接口切换为真实数据 2026-06-12 19:49:17 +08:00
0652a24c5e feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发 2026-06-11 14:02:26 +08:00
dk
d53a8dfae5 fix(加班申请): 去掉撤销相关的状态和动作。
feat(工作报告): 开发工作报告功能
2026-06-11 10:56:24 +08:00
2e369b23a9 refactor(projects): 删除废弃代码 2026-06-05 16:29:35 +08:00
b72ad00912 fix(error-message): 删除用户可见错误文案规范HTML文档
- 移除了完整的用户可见错误文案规范HTML文件
2026-06-04 21:07:44 +08:00
7cc29e0a35 fix(projects): 针对技术负债去优化代码 2026-06-04 21:06:05 +08:00
39458386ae feat(projects): 工作台部分组件调成真实数据 2026-06-04 11:26:51 +08:00
dk
acef4418d8 fix(加班申请): 使用后端专门返回状态的接口,代替使用字典。
fix(status-tag.ts):把产品需求、项目需求的状态颜色定义收敛到此处。
2026-06-04 10:49:34 +08:00
dk
9d84b1aae0 fix(加班申请): 修复加班申请中,和状态机相关的代码的不合理的地方。 2026-06-03 21:04:51 +08:00
dk
d3d0830820 feat(新增加班申请功能): 新增申请功能,可在工作台进行审核。
fix(dict_data): 在字典数据新增、编辑时可以操作颜色类型字段(color_type)。
2026-06-01 21:37:08 +08:00
b2da882b31 feat(execution): 实现执行模块视角切换和快捷过滤功能
- 添加执行视角切换功能(my/all),支持不同身份维度查看
- 实现逾期/本周到期快捷过滤功能,提升执行管理效率
- 重构执行区域UI布局,优化用户体验和界面结构
- 集成Element Plus表单验证,在用户选择器组件中使用
- 优化执行状态筛选和计数逻辑,提升数据展示准确性
- 实现执行视角切换时的数据同步刷新机制
- 添加执行完成操作的二次确认对话框
- 重构权限码检查逻辑,统一使用query权限码进行控制
- 移除auth store依赖,精简代码结构
- 优化执行状态看板和任务计数的加载机制
- 实现执行创建和编辑流程的状态同步更新
- 统一任务工作区的执行范围传递方式,提高性能
- 添加执行详情面板的操作按钮权限控制
- 优化执行删除后的数据刷新逻辑,确保视图一致性
2026-05-29 16:40:25 +08:00
4ed4b537ad feat(projects): 工作台小组件设计 2026-05-28 08:20:01 +08:00
3988eaf910 refactor(workbench): 重构待办面板功能提升用户体验
- 替换原有时间桶过滤为分类标签页和截止时间筛选器
- 添加优先级排序功能,支持任务类别内按优先级排序
- 重构待办数据结构,新增创建时间和优先级字段
- 移除高优先级标记,统一使用优先级枚举值
- 添加个人事项创建对话框和相关操作功能
- 更新模拟数据以匹配新的数据结构和功能需求
- 优化列表排序逻辑,按创建时间升序排列,无截止时间排最后
- 为各类别待办项添加逾期状态标识和计数统计
- 实现分页加载,每页显示5条待办记录
- 更新样式类名以匹配新的逾期判断逻辑

refactor(project): 优化项目执行模块提升性能和可维护性

- 移除执行项点击切换功能相关的事件和方法
- 删除不再使用的select-execution事件发射器
- 移除执行标签的悬停效果和鼠标指针样式
- 重构任务表格视图,将日期格式化函数名称标准化
- 在跨执行模式下也显示进度列,统一界面布局
- 更新最近更新列宽度并调整日期格式显示
- 将默认页面大小从10增加到20以提高加载效率

feat(list): 统一日期格式化功能简化代码维护

- 将日期时间格式化函数重命名为更准确的date格式化
- 在产品列表和项目列表中统一使用新的日期格式化函数
- 移除秒数显示,仅保留年月日格式提高可读性

refactor(todo): 重构待办事项数据模型和过滤逻辑

- 重新定义待办事项分类类型,移除mention添加personal
- 新增主标签、截止时间筛选器和优先级类型定义
- 添加创建时间字段用于排序和显示
- 实现基于分类、截止时间和优先级的过滤函数
- 创建优先级权重映射用于排序算法
- 更新待办项构建函数以支持新的排序逻辑
- 修改逾期判断逻辑以适应新的数据结构
- 移除原有的高优先级字段,统一使用优先级枚举
- 添加优先级排序功能支持升序降序切换
- 重构排序算法,优先按创建时间,其次按截止时间排序

refactor(task): 清理任务模块中已废弃的功能

- 移除通过ID选择执行项的相关函数和事件处理器
- 删除任务卡片和表格中的执行项点击切换功能
- 更新任务工作区组件以移除废弃的事件监听
- 调整任务表格视图中进度条的样式和状态显示

refactor(components): 项目列表中添加进度条可视化组件

- 引入Element Plus进度条组件用于项目进度展示
- 在项目列表中添加进度列并实现进度条渲染
- 配置进度条样式包括内嵌文字、成功状态和边框圆角
- 调整进度列宽度以适应进度条显示需求

refactor(widgets): 整理工作台模块配置和清理冗余组件

- 从工作台模块注册中移除已废弃的myTicket组件
- 更新模块注释说明,明确myTicket已废弃的原因
- 删除不再使用的workbench-my-ticket.vue组件文件
- 更新模块总数注释从16个调整为15个
2026-05-25 14:30:44 +08:00
e9214137c1 refactor(project): 重构项目执行模块组件结构和数据管理
- 移除 execution-list-panel.vue 组件并将功能整合到执行区域
- 新增 execution-section.vue 组件替代原有的列表面板
- 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用
- 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理
- 添加跨执行任务状态统计接口调用和数据处理逻辑
- 重构执行状态筛选和任务创建权限判断逻辑
- 更新执行选择、搜索和重置功能的事件处理方式
- 调整页面布局结构,优化左右分栏的内容组织方式
- 完善执行详情获取和状态操作的业务流程
- 优化执行分配和状态变更的异步处理机制
2026-05-23 14:22:58 +08:00
dk
13b74cfe97 feat(新增需求评审功能): 新增需求评审功能。
feat(动态切换对象域下的对象):对象域下的对象可以动态切换。
fix(产品需求、项目需求): 按照会议意见修改诸多细节。
fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
2026-05-22 14:05:25 +08:00
caozehui
ab882e085b feat(personal-center): 重构个人事项详情并复用任务工作日志组件 2026-05-22 10:46:46 +08:00
62859bfc38 fix(projects): 工作日志编辑日期不回填 2026-05-21 22:05:30 +08:00
ba328e02bb refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题 2026-05-21 21:42:23 +08:00
caozehui
28d597d91e fix(personal-item): 个人事项&任务添加type类型字段 2026-05-21 14:06:05 +08:00
caozehui
fe29fde564 Merge remote-tracking branch 'origin/main' 2026-05-21 10:44:20 +08:00
caozehui
7d578ab271 feat(personal-item): 个人事项 2026-05-21 10:44:00 +08:00
caozehui
71da2d507e fix(personal-center): 个人头像更新 2026-05-19 10:59:07 +08:00
acd41555f9 refactor(projects): 1、优化新增 产品和新增项目;2、调整角色提示信息 2026-05-18 22:25:04 +08:00
dk
2367e03146 fix(产品需求、项目需求): 按照会议所说进行修改。 2026-05-18 16:49:12 +08:00
caozehui
023490c012 fix(infra): 分页查询列表隐藏非必要字段 2026-05-18 14:57:48 +08:00
caozehui
29ef03c40f Merge remote-tracking branch 'origin/main' 2026-05-18 13:19:45 +08:00
387eb41412 fix(auth): 修复令牌过期处理和会话失效通知机制
- 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码
- 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000
- 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth
- 添加 skipAuth 配置选项避免给公开接口带上过期 access 头
- 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示
- 在登录成功后复位会话失效标志以支持下次正常提示
- 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn
2026-05-18 08:29:51 +08:00
caozehui
480714172e feat(personal-center): 实现个人信息功能 2026-05-15 16:05:56 +08:00
caozehui
0c6ed249ee Merge remote-tracking branch 'origin/main' 2026-05-15 14:19:50 +08:00
543d1a59a9 fix(auth): 修复令牌过期处理和会话失效通知机制
- 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码
- 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000
- 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth
- 添加 skipAuth 配置选项避免给公开接口带上过期 access 头
- 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示
- 在登录成功后复位会话失效标志以支持下次正常提示
- 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn
2026-05-15 13:38:41 +08:00
caozehui
3ad30b4f39 fix(role): 优化角色资源树选中ID处理逻辑 2026-05-15 13:16:14 +08:00
caozehui
14e0502d16 Merge remote-tracking branch 'origin/main' 2026-05-15 10:56:34 +08:00
caozehui
d43f999b96 Merge branch 'codex-worktree-20260515-094316' 2026-05-15 10:56:03 +08:00
caozehui
8b34147868 fix(system-role): 修复角色资源树联动授权提交 2026-05-15 10:54:26 +08:00
7a4d831c10 feat(file): 优化文件上传处理和ID管理规范
- 新增 buildFileProxyUrl 函数构建永久代理路径,避免富文本图片链接过期
- 重构 uploadFile 函数,统一将后端返回的数值型 ID 转换为字符串
- 在业务富文本编辑器中使用永久代理路径替换临时签名 URL
- 完善 API 适配层 ID 规范,确保所有 ID 字段统一转换为字符串类型
- 移除废弃的编辑器相关路由和组件
- 更新构建代理配置以支持富文本图片直连访问
- 删除冗余的类型定义和依赖包
2026-05-15 10:06:51 +08:00
caozehui
3a064eb09f feat(infra): 新增状态机管理功能模块
- 新增状态机模型和状态流转的完整 CRUD 功能
- 添加字典编码 OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE 用于对象类型下拉选择
- 实现状态机列表页、搜索组件、操作对话框和状态流转管理
- 新增 infra API 接口封装和类型定义
- 遵循项目规范:使用 TableSearchFields 搜索组件、BusinessTableActionCell 操作列、统一的状态标签展示

涉及文件:
- src/constants/dict.ts: 新增对象类型字典编码
- src/service/api/infra.ts: 新增状态机和状态流转相关 API
- src/typings/api/infra.d.ts: 新增状态机相关类型定义
- src/views/infra/state-machine/: 新增状态机管理页面及子组件
2026-05-15 09:31:00 +08:00
960fe805ec docs(api): 删除关心人功能和成员列表接口文档
- 移除关心人功能API接口文档文件
- 移除成员列表接口变更前端对接说明文档
- 清理相关HTML格式的API文档文件
2026-05-14 14:12:35 +08:00
59b73f3dae refactor(projects): 优化产品项目新增逻辑 2026-05-14 14:11:16 +08:00
ddd05f8c02 feat(projects): 1、增加空白页占位;2、调试已开发功能; 2026-05-14 09:05:08 +08:00
dk
f634d21d2a feat(产品需求、项目需求): 开发两种需求的富文本和附件功能。 2026-05-13 23:09:35 +08:00
dk
e3a456debd Merge branch 'main' of http://192.168.1.22:3000/Web/cn-rdms-web
# Conflicts:
#	src/service/api/product.ts
#	src/service/api/project.ts
#	src/typings/api/project.d.ts
2026-05-13 21:20:59 +08:00
dk
60debcda8a feat(项目需求): 开发项目需求的功能。 2026-05-13 21:13:21 +08:00
5615399a68 feat(projects): 1、执行、任务、工作日志开发调试;2、增加富文本、附件等支撑 2026-05-12 21:41:39 +08:00
dk
28c47b14a3 fix(产品需求): 完善产品需求的诸多细节。 2026-05-09 18:15:10 +08:00
dk
5947157f89 Merge branch 'main' of http://192.168.1.22:3000/Web/cn-rdms-web
# Conflicts:
#	src/views/product/requirement/index.vue
#	src/views/system/user-management-relation/index.vue
2026-05-09 13:44:08 +08:00
dk
f0ea903d59 fix(产品需求): 修复产品需求在测试后存在的问题。 2026-05-09 13:42:04 +08:00
329 changed files with 62125 additions and 15717 deletions

View File

@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpm gen-route *)",
"Bash(pnpm typecheck *)",
"Bash(pnpm lint *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(Remove-Item *)",
"PowerShell(pnpm typecheck *)",
"WebFetch(domain:www.wangeditor.com)"
]
}
}

8
.env
View File

@@ -2,9 +2,9 @@
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin" # 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
VITE_BASE_URL=/ VITE_BASE_URL=/
VITE_APP_TITLE=研发内部管理系统 VITE_APP_TITLE=研发管理系统
VITE_APP_DESC=Frontend application for 灿能研发内部管理系统 VITE_APP_DESC=Frontend application for 灿能研发管理系统
# 图标名称前缀 # 图标名称前缀
VITE_ICON_PREFIX=icon VITE_ICON_PREFIX=icon
@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页 # 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
# 典型场景token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录 # 典型场景token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
VITE_SERVICE_LOGOUT_CODES=401,1002023000 VITE_SERVICE_LOGOUT_CODES=401
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出 # 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录 # 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求 # token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
# 典型场景accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期 # 典型场景accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001 VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
# 静态路由模式下定义的超级管理员角色 # 静态路由模式下定义的超级管理员角色
VITE_STATIC_SUPER_ROLE=R_SUPER VITE_STATIC_SUPER_ROLE=R_SUPER

4
.gitignore vendored
View File

@@ -38,5 +38,9 @@ yarn.lock
/docs/* /docs/*
!/docs/frontend-page-resource-manifest.json !/docs/frontend-page-resource-manifest.json
# Claude
/.claude/*
# Temp # Temp
/codeTemp/* /codeTemp/*
SKILL.md

2
.trae/rules/vue-need.md Normal file
View File

@@ -0,0 +1,2 @@
1. 每次开发新功能、编写代码时都添加好相应的注释。
2. 所有的vue文件编码必须是UTF-8的。

View File

@@ -11,6 +11,8 @@
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。 默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
## 交互与执行原则 ## 交互与执行原则
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。 - 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
@@ -173,6 +175,31 @@
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts``src/router/routes/*``src/store/modules/route/*` 和相关文档。 - 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts``src/router/routes/*``src/store/modules/route/*` 和相关文档。
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。 - 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
## 防重复提交(两层联防)
用户快速双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮内置无 loading 等场景,都可能让同一写操作发出多次。仓库采用两层防御,新增写操作功能时按顺序检查:
### 第一层:业务按钮的 loading 锁(视觉防御)
- 新增、编辑入口优先使用 `src/components/custom/business-form-dialog.vue``src/components/custom/business-form-drawer.vue`,它们在 `submit` 流程内 await 接口期间会自动将"确认"按钮置为 `loading` + `disabled`
- 不要裸手写 `<ElButton @click="submit">` 直接调接口;若必须使用裸 `ElButton`,需要自行绑定 `:loading` 并在 await 接口期间锁住按钮。
- 删除二次确认使用 `ElMessageBox.confirm` 时,其内部"确定"按钮没有 loading 能力,必须依赖第二层兜底,不要尝试改造 confirm 的内部按钮。
### 第二层:请求层全局去重(逻辑兜底)
- 入口:`src/service/request/dedupe.ts` 提供 `withDedupe`,已在 `src/service/request/index.ts` 包住统一的 `request` 实例;`demoRequest` 未启用。
- 指纹:`method + 完整 URL + 排序后的 params + 稳定序列化的 body`body 内对象按 key 排序,数组保序。
- 行为:写操作(`POST` / `PUT` / `DELETE` / `PATCH`)在第一次请求 pending 期内,若再次发起指纹相同的请求,自动复用第一次的 Promise不发出第二次实际请求调用方两次拿到完全相同的返回对象。
- 跳过条件(即不去重,按原逻辑发出):`GET` / `HEAD` / `OPTIONS`,请求体为 `FormData``Blob`(上传场景),调用方显式传 `{ dedupe: false }`
- 业务调用方零感知:新增接口默认即享受兜底,不需要在 `src/service/api/*` 或页面层做任何改动。
- 极少数业务确实允许短时间内并发提交完全相同的写请求时,在调用处显式传 `request({ ..., dedupe: false })` 单接口关闭。
- 兜底超时 30 秒:极端情况下若某次 Promise 未 settlepending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
### 设计责任划分
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
## 运行时字典使用口径 ## 运行时字典使用口径
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。 - 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
@@ -235,6 +262,7 @@ const directionLabels = getLabels(row.directionCodes, { separator: '' });
- 如果后端当前接口暂时还返回数值型 ID前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。 - 如果后端当前接口暂时还返回数值型 ID前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。 - 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。 - 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
- API 适配层兜底(实操约束):所有从后端接收的数值型 ID 字段(不论后端实际返回 `string``number` 或两者混合),都必须在 `src/service/api/*` 的 normalize 或 map 函数中显式调用 `String(rawId)` 归一一次;前端业务层(`views``store`、组件、`Map` 键、路由参数)只接收 `string` 形态,永远不需要自己 `String()`。这条与后端是否做了 Long → String 全局序列化无关——后端做了是双保险,没做且字段取值始终在 JS 安全整数内(例如 `infra_file_config.id` 永远是两位数)也是合理选择,前端 normalize 已经把口径收死,业务层无感。但这条不开按字段取值范围豁免的口子:前端 normalize 是无差别的,任何 ID 都要 `String()`,不要按某个字段当前取值大小决定要不要走 normalize避免后续逐步污染仓库的 ID 纪律。
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID当前任务触达相关链路时优先顺手矫正”不要继续复制历史写法。 - 对仓库中的历史代码,原则是“不再新增 number 口径 ID当前任务触达相关链路时优先顺手矫正”不要继续复制历史写法。
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。 - 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
- 修改界面时优先延续 `src/layouts``src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。 - 修改界面时优先延续 `src/layouts``src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。

View File

@@ -10,6 +10,7 @@
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。 - **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。 - **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。 - **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。 - **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
- **不主动执行 git 操作**status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。 - **不主动执行 git 操作**status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
@@ -284,6 +285,15 @@ const directionLabels = getLabels(row.directionCodes, { separator: '' });
- **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。 - **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。
- 最稳妥契约:**后端 Long ID 直接按字符串返回**;前端全链路按字符串。后端未改,前端也不得新增 `number` 口径 ID。 - 最稳妥契约:**后端 Long ID 直接按字符串返回**;前端全链路按字符串。后端未改,前端也不得新增 `number` 口径 ID。
### API 适配层兜底(操作约束)
- 所有从后端接收的数值型 ID 字段,**必须**在 `src/service/api/*` 的 normalize/map 函数里显式 `String(rawId)` 一次——**不管后端返回 string、number、还是混合**。
- 业务层views / store / 组件 / `Map` key / 路由参数)**只接收 string**,从不需要自己 `String()`
- 与"后端是否已经全局 Long → String"**无关**
- 后端做了 → 双保险
- 后端没做但取值在 JS 安全整数内 → 单层防御也对(实际值不丢精度)
- 后端没做且取值超安全整数 → 不安全,必须推后端改
- **不开"按取值范围豁免"的口子**:哪怕后端说"这个字段永远是两位数"(如 `infra_file_config.id`),前端照样 `String()`。否则后续会冒出"projectStatus 是 Long 但只有 0-99也可以保留 number"等连锁例外,铁律字面被掏空。
### 历史代码原则 ### 历史代码原则
不再新增 `number` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。 不再新增 `number` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。
@@ -368,3 +378,54 @@ pnpm preview # preview server (9725)
- 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)` - 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)`
- 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map调用方写一个 wrapper 即可。 - 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map调用方写一个 wrapper 即可。
- 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 helper前端兜底 - 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 helper前端兜底
---
## 19. 防重复提交(两层联防,强约束)
> 用户双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮无内置 loading 都会让同一写操作发多次。两层防御缺一不可。
### 两层各自的职责
| 层 | 谁负责 | 行为 |
|---|---|---|
| 视觉层 | `business-form-dialog.vue` / `business-form-drawer.vue` | submit 触发后立即把"确认"按钮置 loading + disabled挡住二次点击 |
| 逻辑层(兜底) | `src/service/request/dedupe.ts`(已通过 `withDedupe` 包住 `request` 实例) | 写操作 pending 期内复用同一 Promise不真正发出第二次请求 |
### 业务侧关注点
- **不要裸手写** `<ElButton @click="submit">` 调接口;用 `business-form-dialog` / `business-form-drawer` 包;非要用裸 `ElButton` 时**必须**自行绑 `:loading` 并在 await 期间锁住。
- **`ElMessageBox.confirm` 的"确定"按钮没 loading 能力**——不要尝试改它,靠第二层兜底就够。
- **新接口默认享受去重**,调用方零改动;不要在 `src/service/api/*` 或页面层再造一套去重。
### 去重生效边界
- 自动去重:`POST` / `PUT` / `DELETE` / `PATCH`
- 不去重:`GET` / `HEAD` / `OPTIONS`(避免误伤分页 / 多 widget 并发查询);请求体为 `FormData` / `Blob`(上传场景)。
- 单接口逃生口:`request({ ..., dedupe: false })`——极少用,仅当业务真允许短时间内连发完全相同的写请求。
- 兜底超时 30s保险丝防止 Promise 永不 settle 时内存泄漏。
### 指纹算法
`method 大写 | URL + 排序后的 params 序列化 | 稳定序列化的 body`。body 内对象按 key 排序、数组保序——保证调用顺序不同但参数等价的两次请求拿到同一指纹。
### 何时回到本节查
- 新建写操作页面 → 视觉层用对组件、不裸 `ElButton` 调接口
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
---
## 20. 我生成文档的输出格式(强约束)
- **superpowers 工作流(`docs/superpowers/plans/``docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html``技术负债台账.html`)的样式骨架:
- 单文件、内联 CSS
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
- 14px / `line-height: 1.7``PingFang SC` / `Microsoft YaHei` 中文字体优先
- 模块化区块:`section` + 编号 h2、`card``table.cmp``pre``tag-ok/warn/bad/crit`
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。

View File

@@ -1,35 +0,0 @@
# cn-rdms-web
这是当前项目的前端工程仓库。
原开源模板项目的介绍内容已移除,这个 README 现在只保留当前项目自身所需的信息。
## 项目说明
待补充。
建议后续在这里补充:
- 项目背景
- 技术栈
- 目录结构
- 本地启动方式
- 环境变量说明
- 构建与发布流程
## 本地开发
```bash
pnpm install
pnpm dev
```
## 常用命令
```bash
pnpm dev
pnpm build
pnpm build:dev
pnpm typecheck
pnpm lint
```

View File

@@ -1,6 +1,7 @@
import type { ProxyOptions } from 'vite'; import type { ProxyOptions } from 'vite';
import { bgRed, bgYellow, green, lightBlue } from 'kolorist'; import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
import { consola } from 'consola'; import { consola } from 'consola';
import { WEB_SERVICE_PREFIX } from '../../src/constants/service';
import { createServiceConfig } from '../../src/utils/service'; import { createServiceConfig } from '../../src/utils/service';
/** /**
@@ -24,6 +25,14 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
Object.assign(proxy, createProxyItem(item, isEnableProxyLog)); Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
}); });
// 富文本图片 <img src="/admin-api/system/file/{configId}/get/{path}"> 由浏览器直接发起,
// 不经过 axios没有 baseURL 前缀。这里加一条原样透传,避免被 Vite SPA fallback 兜底成 index.html。
// 不带 rewrite —— 原样把 /admin-api/* 转发到后端;不影响现有 /proxy-default 链路。
proxy[WEB_SERVICE_PREFIX] = {
target: baseURL,
changeOrigin: true
};
return proxy; return proxy;
} }

View File

@@ -27,8 +27,13 @@ export function setupElegantRouter() {
onRouteMetaGen(routeName) { onRouteMetaGen(routeName) {
const key = routeName as RouteKey; const key = routeName as RouteKey;
const constantRoutes: RouteKey[] = ['login', '403', '404', '500']; const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench'];
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = { const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
workbench: {
icon: 'mdi:view-dashboard-outline',
order: 1,
keepAlive: true
},
product: { product: {
icon: 'carbon:product', icon: 'carbon:product',
order: 4 order: 4
@@ -79,6 +84,90 @@ export function setupElegantRouter() {
hideInMenu: true, hideInMenu: true,
activeMenu: 'project_list' activeMenu: 'project_list'
}, },
ticket: {
icon: 'mdi:ticket-confirmation-outline',
order: 6
},
'ticket_my-submitted': {
icon: 'mdi:upload-outline',
order: 1,
keepAlive: true
},
'ticket_my-pending': {
icon: 'mdi:inbox-arrow-down-outline',
order: 2,
keepAlive: true
},
metrics: {
icon: 'mdi:chart-line',
order: 7
},
'metrics_project-progress': {
icon: 'mdi:progress-clock',
order: 1,
keepAlive: true
},
'metrics_member-efficiency': {
icon: 'mdi:account-multiple-check-outline',
order: 2,
keepAlive: true
},
metrics_worktime: {
icon: 'mdi:clock-time-five-outline',
order: 3,
keepAlive: true
},
'personal-center': {
icon: 'mdi:account-circle-outline',
order: 8
},
'personal-center_my-profile': {
icon: 'mdi:account-box-outline',
order: 0,
keepAlive: true
},
'personal-center_my-item': {
icon: 'mdi:checkbox-multiple-blank-circle-outline',
order: 1,
keepAlive: true
},
'personal-center_work-report': {
icon: 'mdi:file-chart-outline',
order: 3,
keepAlive: true
},
'personal-center_work-report_weekly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_monthly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_project': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_my-performance': {
icon: 'mdi:trophy-outline',
order: 4,
keepAlive: true
},
'personal-center_my-application': {
icon: 'mdi:file-document-outline',
order: 5,
keepAlive: true
},
'personal-center_overtime-application': {
icon: 'mdi:clock-plus-outline',
order: 6,
keepAlive: true
},
'personal-center_pending-approval': {
icon: 'mdi:check-decagram-outline',
order: 7,
keepAlive: true
},
system: { system: {
icon: 'carbon:cloud-service-management', icon: 'carbon:cloud-service-management',
order: 9, order: 9,
@@ -110,6 +199,20 @@ export function setupElegantRouter() {
hideInMenu: true, hideInMenu: true,
roles: ['R_ADMIN'], roles: ['R_ADMIN'],
activeMenu: 'system_user' activeMenu: 'system_user'
},
infra: {
icon: 'ep:monitor',
order: 20
},
'infra_state-machine': {
icon: 'mdi:state-machine',
order: 1,
keepAlive: true
},
'infra_rd-code': {
icon: 'mdi:identifier',
order: 2,
keepAlive: true
} }
}; };

View File

@@ -1,12 +1,12 @@
{ {
"generatedAt": "2026-04-29T08:18:14.397Z", "generatedAt": "2026-06-05T03:08:01.803Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>", "pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>" "singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
}, },
"total": 8, "total": 22,
"items": [ "items": [
{ {
"name": "product_list", "name": "product_list",
@@ -74,6 +74,402 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "ticket_my-submitted",
"path": "/ticket/my-submitted",
"component": "view.ticket_my-submitted",
"title": "我提交的工单",
"routeTitle": "ticket_my-submitted",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我提交的工单",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "ticket",
"pageType": "leaf",
"source": "generated"
},
{
"name": "ticket_my-pending",
"path": "/ticket/my-pending",
"component": "view.ticket_my-pending",
"title": "待我处理的工单",
"routeTitle": "ticket_my-pending",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "待我处理的工单",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "ticket",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_project-progress",
"path": "/metrics/project-progress",
"component": "view.metrics_project-progress",
"title": "项目进度",
"routeTitle": "metrics_project-progress",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "项目进度",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_member-efficiency",
"path": "/metrics/member-efficiency",
"component": "view.metrics_member-efficiency",
"title": "员工能效",
"routeTitle": "metrics_member-efficiency",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "员工能效",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_worktime",
"path": "/metrics/worktime",
"component": "view.metrics_worktime",
"title": "工时统计",
"routeTitle": "metrics_worktime",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
"localIcon": null,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "工时统计",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
"localIcon": null,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-profile",
"path": "/personal-center/my-profile",
"component": "view.personal-center_my-profile",
"title": "个人信息",
"routeTitle": "personal-center_my-profile",
"i18nKey": "route.personal-center_my-profile",
"icon": "mdi:account-box-outline",
"localIcon": null,
"order": 0,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "个人信息",
"i18nKey": "route.personal-center_my-profile",
"icon": "mdi:account-box-outline",
"localIcon": null,
"order": 0,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-item",
"path": "/personal-center/my-item",
"component": "view.personal-center_my-item",
"title": "我的事项",
"routeTitle": "personal-center_my-item",
"i18nKey": "route.personal-center_my-item",
"icon": "mdi:checkbox-multiple-blank-circle-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的事项",
"i18nKey": "route.personal-center_my-item",
"icon": "mdi:checkbox-multiple-blank-circle-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_work-report",
"path": "/personal-center/work-report",
"component": "view.personal-center_work-report",
"title": "工作报告",
"routeTitle": "personal-center_work-report",
"i18nKey": "route.personal-center_work-report",
"icon": "mdi:file-chart-outline",
"localIcon": null,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "工作报告",
"i18nKey": "route.personal-center_work-report",
"icon": "mdi:file-chart-outline",
"localIcon": null,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-performance",
"path": "/personal-center/my-performance",
"component": "view.personal-center_my-performance",
"title": "我的绩效",
"routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 4,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的绩效",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 4,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-application",
"path": "/personal-center/my-application",
"component": "view.personal-center_my-application",
"title": "我的申请",
"routeTitle": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 5,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的申请",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 5,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "待我审批",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "待我审批",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application",
"component": "view.personal-center_overtime-application",
"title": "加班申请",
"routeTitle": "personal-center_overtime-application",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "加班申请",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "system_user", "name": "system_user",
"path": "/system/user", "path": "/system/user",
@@ -271,6 +667,72 @@
"parentName": "system", "parentName": "system",
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
},
{
"name": "infra_state-machine",
"path": "/infra/state-machine",
"component": "view.infra_state-machine",
"title": "状态机管理",
"routeTitle": "infra_state-machine",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "状态机管理",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
},
{
"name": "infra_rd-code",
"path": "/infra/rd-code",
"component": "view.infra_rd-code",
"title": "研发令号",
"routeTitle": "infra_rd-code",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "研发令号",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
} }
] ]
} }

View File

@@ -1,292 +0,0 @@
# 产品对象首页改版设计说明
日期2026-04-23
## 1. 目标
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
- 第一眼先让用户知道当前看的是什么产品
- 第二眼能快速判断对象最近发生了什么
- 第三眼能看出需求池现在的经营状态和最近变化
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
## 2. 已确认诉求
基于本轮对话,已确认以下用户诉求:
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
6. 快捷入口不要保留
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
## 3. 首页定位结论
本页定位不是:
- 纯报表看板
- 纯审计日志页
- 设置页搬运版
- 导航入口集合页
本页定位应当是:
- 产品对象首页
- 偏统计,也带审计
- 但页面主语始终是“当前产品对象”
换句话说,这个页面要同时回答三个问题:
1. 我现在看的是什么产品?
2. 这个产品对象最近发生了什么?
3. 这个产品的需求池现在处于什么状态?
## 4. 页面结构
### 4.1 桌面端结构
桌面端建议采用三层结构:
1. 顶部 `对象基础概述横幅`
2. 中部 `左时间线 + 右需求池双模块`
3. 底部 `扩展信息区`
推荐布局比例:
- 顶部横幅:`24 / 24`
- 中部主区:左 `16 / 24`,右 `8 / 24`
- 底部扩展区:`24 / 24`
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
### 4.2 移动端结构
移动端统一退化为单列纵向布局,顺序为:
1. 对象基础概述横幅
2. 对象 / 团队动态时间线
3. 需求池管理概览
4. 需求池最近变化
5. 扩展信息区
移动端不强撑左右栏并排,不做卡片墙式压缩。
## 5. 模块设计
### 5.1 对象基础概述横幅
顶部采用“档案横幅型”,不采用纯指标卡片型。
横幅左侧承接对象身份信息:
- 产品名称
- 产品编号
- 当前状态标签
- 产品经理
- 团队规模
- 团队角色摘要
- 简短描述或备注
横幅右侧承接 4 个摘要指标:
- 团队人数
- 需求总量
- 待处理需求
- 最近动态时间
设计原则:
- 左侧负责建立对象识别
- 右侧负责快速判断当前概况
- 右侧指标只保留 4 项,不堆成报表卡片墙
### 5.2 对象 / 团队动态时间线
该区域位于中部左侧,是首页的主阅读区。
这条时间线只承接对象与团队变化,不承接需求事件。
第一版事件范围收敛为:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
- 成员角色调整
每条时间线建议展示:
- 事件标题
- 事件类型标签
- 发生时间
- 操作摘要
- 必要时展示原因或备注
表达目标是“业务时间线”,不是后台审计表格。
### 5.3 需求池管理概览
该区域位于中部右侧上半块,用于表达需求池的经营状态。
第一版首页需要优先看到的内容:
- 需求总量
- 各状态数量
- 待处理数量
- 高优先级待处理数量
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
这一块回答的是:
- 需求池是否健康
- 当前待处理压力大不大
- 是否存在需要优先关注的积压
### 5.4 需求池最近变化
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
该区域不重复展示总量,而是展示需求池最近发生的变化。
第一版建议承接:
- 最近新增需求
- 最近状态流转
- 最近关闭或完成
每条记录建议至少展示:
- 需求标题
- 动作类型
- 时间
- 当前状态或状态变更摘要
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
### 5.5 扩展信息区
底部不再保留快捷入口,改为正式扩展信息区。
当前优先预留 3 类模块位:
- 里程碑
- 风险点管理
- 产品资料
这一层的作用是:
- 为后续对象级信息继续扩展留下稳定挂载位
- 不把中部主结构挤成信息大杂烩
- 避免为了未来模块提前做假导航入口
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
## 6. 数据策略
### 6.1 真实接口优先
当前首页优先消费现有真实接口:
- `fetchGetProduct`
- `fetchGetProductSettings`
- `fetchGetProductMembers`
这些接口足以支撑:
- 对象基础概述中的名称、编号、状态、产品经理、描述
- 团队人数与角色摘要
- 最近动态中的产品创建、状态变化、成员加入/移出
### 6.2 假数据使用边界
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
- 需求池管理概览
- 需求池最近变化
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
假数据的使用原则:
1. 只补“当前没有稳定接口”的区域
2. 不反向污染对象基础信息
3. 不把假数据混入对象上下文 store
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
### 6.3 不推荐的做法
以下做法应避免:
- 把需求假数据散落写进页面组件
- 用对象 demo 数据冒充真实产品详情
- 把对象时间线和需求时间线混成一条
- 用快捷入口伪装成首页内容
## 7. 空态规则
首页至少要区分三种状态:
1. 能力未接入,只能先显示正式占位信息
2. 能力已接入,但当前该产品暂无业务数据
3. 当前用户无权限查看该模块
这三种状态不能共用一套模糊文案。
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
## 8. 页面边界
首页明确不承接以下内容:
- 快捷入口导航区
- 完整团队成员表格
- 完整需求列表表格
- 设置页重表单
- 完整审计日志明细页
首页要做的是概述、判断与阅读,不是重操作页。
## 9. 实施建议
第一阶段建议先完成结构性改造:
1. 重做顶部横幅,建立对象档案感
2. 保留中部左高右双块结构
3. 用真实接口接通对象概述与对象 / 团队时间线
4. 用局部 mock 数据先接通需求池两块和底部扩展区
第二阶段再逐步替换需求池与扩展区数据源:
- 接真实需求池统计接口
- 接真实需求动态接口
- 接里程碑、风险点、产品资料摘要接口
## 10. 验证标准
本设计是否成立,可按以下标准判断:
1. 进入首页后,第一眼能认出当前产品对象
2. 用户能自然读到对象 / 团队最近发生了什么
3. 右侧能快速判断需求池当前压力与最近变化
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
## 11. 本轮设计结论
本轮最终设计结论如下:
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
- 顶部采用档案横幅型,先立住对象身份信息
- 中部左侧是高权重的对象 / 团队动态时间线
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
- 底部去掉快捷入口,改为正式扩展信息区
- 当前有真实接口的模块优先接真实接口
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中

View File

@@ -37,9 +37,6 @@
"update-pkg": "sa update-pkg" "update-pkg": "sa update-pkg"
}, },
"dependencies": { "dependencies": {
"@antv/data-set": "0.11.8",
"@antv/g2": "5.4.0",
"@antv/g6": "5.0.49",
"@better-scroll/core": "2.5.1", "@better-scroll/core": "2.5.1",
"@iconify/vue": "5.0.0", "@iconify/vue": "5.0.0",
"@sa/axios": "workspace:*", "@sa/axios": "workspace:*",
@@ -47,50 +44,32 @@
"@sa/hooks": "workspace:*", "@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*", "@sa/materials": "workspace:*",
"@sa/utils": "workspace:*", "@sa/utils": "workspace:*",
"@visactor/vchart": "2.0.4",
"@visactor/vchart-theme": "1.12.2",
"@visactor/vtable-editors": "1.19.8",
"@visactor/vtable-gantt": "1.19.8",
"@visactor/vue-vtable": "1.19.8",
"@vueuse/components": "13.9.0",
"@vueuse/core": "13.9.0", "@vueuse/core": "13.9.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "1.11.18", "dayjs": "1.11.18",
"defu": "^6.1.4", "defu": "^6.1.4",
"dhtmlx-gantt": "9.0.14",
"dompurify": "3.2.6", "dompurify": "3.2.6",
"echarts": "6.0.0", "echarts": "6.0.0",
"element-plus": "^2.11.1", "element-plus": "^2.11.1",
"jsbarcode": "3.12.1", "grid-layout-plus": "^1.1.1",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"json5": "2.2.3", "json5": "2.2.3",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.3", "pinia": "3.0.3",
"pinyin-pro": "3.27.0",
"print-js": "1.6.0",
"swiper": "11.2.10",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"typeit": "8.8.7",
"vditor": "3.11.2",
"vue": "3.5.20", "vue": "3.5.20",
"vue-draggable-plus": "0.6.0", "vue-draggable-plus": "0.6.0",
"vue-i18n": "11.1.11", "vue-i18n": "11.1.11",
"vue-pdf-embed": "2.1.3", "vue-router": "4.5.1"
"vue-router": "4.5.1",
"xgplayer": "3.0.23",
"xlsx": "0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@amap/amap-jsapi-types": "0.0.15",
"@elegant-router/vue": "0.3.8", "@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.380", "@iconify/json": "2.2.380",
"@sa/scripts": "workspace:*", "@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*", "@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.1", "@soybeanjs/eslint-config": "1.7.1",
"@types/bmapgl": "0.0.7",
"@types/dompurify": "3.2.0",
"@types/node": "24.3.0", "@types/node": "24.3.0",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.5.0", "@unocss/eslint-config": "66.5.0",

2447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,718 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessAttachmentUploader' });
interface Props {
/** 上传目录,传给后端 directory 字段 */
directory?: string;
/** 数量上限,默认 20与后端 AttachmentValidator 一致) */
max?: number;
/** 单文件大小上限 MB前端兜底最终由 /system/file/upload 拦截) */
maxFileSizeMB?: number;
disabled?: boolean;
/**
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
*/
flat?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
directory: undefined,
max: 20,
maxFileSizeMB: 50,
disabled: false,
flat: false
});
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
/** 给用户看的简短分类hint 行展示) */
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
// 与后端 AttachmentValidator 白/黑名单保持一致5.16
const ALLOWED_EXTENSIONS = new Set([
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv',
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'zip',
'rar',
'7z',
'mp4',
'mp3'
]);
const FORBIDDEN_EXTENSIONS = new Set([
'exe',
'bat',
'cmd',
'sh',
'ps1',
'msi',
'dll',
'jar',
'war',
'php',
'jsp',
'asp',
'aspx',
'py',
'rb',
'pl',
'com',
'scr',
'vbs',
'js'
]);
interface PendingItem {
id: string;
name: string;
}
const pending = ref<PendingItem[]>([]);
const inputRef = ref<HTMLInputElement>();
const isUnmounting = ref(false);
/**
* 会话级清理账本:
* - originalIds: 弹层打开时已存在的 fileId编辑模式下来自 rowData.attachments
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
* - addedIds: 本次会话内上传成功的 fileId
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId含 original 和 added 两类)
* - committed: commit() 调用后置 true阻止后续 rollback 误删
*
* UI 显示 = model已减去 pendingDelete 项)
* 真删时机commit() 删 pendingDeleterollback() 删 addedIds除非 committed
*/
interface UploadSession {
originalIds: Set<string>;
addedIds: Set<string>;
pendingDeleteIds: Set<string>;
committed: boolean;
}
const session = reactive<UploadSession>({
originalIds: new Set<string>(),
addedIds: new Set<string>(),
pendingDeleteIds: new Set<string>(),
committed: false
});
const totalCount = computed(() => model.value.length + pending.value.length);
const isFull = computed(() => totalCount.value >= props.max);
const hasUploading = computed(() => pending.value.length > 0);
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
/**
* 列表区拆成"直接展示"和"折叠浮层"两组:
* - flat全部直接展示适合本身已在 popover 里)
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
*/
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
function isImage(item: Api.Project.AttachmentItem) {
if (item.contentType?.startsWith('image/')) {
return true;
}
return IMAGE_EXTENSIONS.has(getExtension(item.name));
}
interface ImagePreviewState {
visible: boolean;
urls: string[];
}
const imagePreview = reactive<ImagePreviewState>({
visible: false,
urls: []
});
function getExtension(name: string) {
const idx = name.lastIndexOf('.');
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
}
function validateFile(file: File): string | null {
if (!file.name) {
return '文件名为空';
}
if (file.name.length > 255) {
return '文件名超过 255 字符';
}
const ext = getExtension(file.name);
if (!ext) {
return '文件缺少扩展名';
}
if (FORBIDDEN_EXTENSIONS.has(ext)) {
return `不允许上传 .${ext} 文件`;
}
if (!ALLOWED_EXTENSIONS.has(ext)) {
return `暂不支持 .${ext} 文件`;
}
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
}
return null;
}
function triggerSelect() {
if (props.disabled || isFull.value) {
return;
}
inputRef.value?.click();
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = '';
if (files.length === 0) {
return;
}
const remaining = props.max - totalCount.value;
if (files.length > remaining) {
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
return;
}
const validFiles: File[] = [];
files.forEach(file => {
const err = validateFile(file);
if (err) {
window.$message?.error(`${file.name}${err}`);
return;
}
validFiles.push(file);
});
if (validFiles.length === 0) {
return;
}
await Promise.all(validFiles.map(uploadOne));
}
async function uploadOne(file: File) {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
pending.value = [...pending.value, { id: tempId, name: file.name }];
try {
const result = await uploadFile(file, props.directory);
if (result.error || !result.data) {
window.$message?.error(`${file.name}:上传失败`);
return;
}
const { id, url } = result.data;
// 组件已卸载用户上传过程中关弹层onBeforeUnmount 已跑过且看不到这个 id
// 这里立刻调删除,避免孤儿文件
if (isUnmounting.value) {
deleteFile(id).catch(() => {
// 已卸载场景下 console.warn 也访问不到 component scope这里静默吞掉
});
return;
}
model.value = [
...model.value,
{
fileId: id,
url,
name: file.name,
size: file.size,
contentType: file.type || undefined
}
];
session.addedIds.add(id);
} finally {
pending.value = pending.value.filter(item => item.id !== tempId);
}
}
function handleRemove(item: Api.Project.AttachmentItem) {
removeAttachmentByFileId(item.fileId);
}
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
const { data, error } = await downloadFile(item.fileId);
if (error || !data) {
window.$message?.error(`${item.name}:加载失败`);
return null;
}
return URL.createObjectURL(data);
}
async function handleDownload(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
const link = document.createElement('a');
link.href = blobUrl;
link.download = item.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
imagePreview.urls = [blobUrl];
imagePreview.visible = true;
}
function handleClosePreview() {
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
imagePreview.urls = [];
imagePreview.visible = false;
}
/** 文件名点击的统一入口:图片走预览,其余走下载 */
function handleOpen(item: Api.Project.AttachmentItem) {
if (isImage(item)) {
handlePreviewImage(item);
} else {
handleDownload(item);
}
}
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
function removeAttachmentByFileId(fileId: string) {
if (props.disabled) {
return;
}
const idx = model.value.findIndex(item => item.fileId === fileId);
if (idx === -1) {
return;
}
session.pendingDeleteIds.add(fileId);
model.value = model.value.filter((_, i) => i !== idx);
}
function formatSize(size?: number) {
if (!size && size !== 0) {
return '';
}
if (size < 1024) {
return `${size}B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)}KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)}MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
}
/**
* 删除一批 fileId。fire-and-forget
* - 不阻塞 UI任何失败仅 console.warn
* - 后端返回 1001003001文件不存在视为成功
*/
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
}
})
);
}
/** 等关闭弹层时先等再清理。设上限 5s避免极端网络下 commit/rollback 永久挂起。 */
async function waitForPending(maxWaitMs = 5000) {
const start = Date.now();
while (pending.value.length > 0) {
if (Date.now() - start >= maxWaitMs) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
return;
}
// polling: 需要在循环里 awaitsuppress 即可
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => {
setTimeout(resolve, 50);
});
}
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 把当前 model 视为 original清空 added / pendingDelete重置 committed。
*/
initSession() {
session.originalIds = new Set(model.value.map(item => item.fileId));
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 真删 pendingDelete含 original 和 added 两类);置 committed 阻止后续 rollback。
*/
async commit() {
await waitForPending();
const ids = Array.from(session.pendingDeleteIds);
session.pendingDeleteIds.clear();
session.addedIds.clear();
session.committed = true;
await deleteMany(ids);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 真删 addedIds保留 originalcommitted=true 时跳过。
*/
async rollback() {
if (session.committed) {
return;
}
await waitForPending();
const ids = Array.from(session.addedIds);
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = true;
await deleteMany(ids);
},
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
get hasUploading() {
return hasUploading.value;
}
});
onBeforeUnmount(() => {
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
isUnmounting.value = true;
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
// deleteMany 内部已 swallow 单项失败,这里不再 awaitfire-and-forget
if (!session.committed) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(Array.from(session.addedIds));
}
});
</script>
<template>
<div class="business-attachment-uploader">
<div v-if="!disabled" class="business-attachment-uploader__trigger">
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
<span class="business-attachment-uploader__hint">
最多 {{ max }} 已选 {{ totalCount }} 单文件 {{ maxFileSizeMB }}MB
<ElTooltip placement="top">
<template #content>
<div class="business-attachment-uploader__hint-tooltip">
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名{{ acceptExtensionsList }}</div>
</div>
</template>
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
</ElTooltip>
</span>
<input
ref="inputRef"
type="file"
multiple
class="business-attachment-uploader__input"
@change="handleFileChange"
/>
</div>
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
<!-- 直接展示默认仅首项flat 模式全部 -->
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
underline="never"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
<!-- 折叠提示>1 个时显示hover 弹完整列表flat 模式下永不出现 -->
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
<ElPopover
trigger="hover"
placement="bottom-start"
:width="380"
:show-after="200"
popper-class="business-attachment-uploader__popover"
>
<template #reference>
<span class="business-attachment-uploader__more">
还有 {{ popoverAttachments.length }} 个附件
<ElIcon><ArrowDown /></ElIcon>
</span>
</template>
<ul class="business-attachment-uploader__popover-list">
<li
v-for="item in popoverAttachments"
:key="`popover-${item.fileId}`"
class="business-attachment-uploader__item"
>
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
underline="never"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
</ul>
</ElPopover>
</li>
<!-- pending 项不折叠让用户能持续看到上传进度 -->
<li
v-for="item in pending"
:key="`pending-${item.id}`"
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
>
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
<span class="business-attachment-uploader__status">上传中</span>
</li>
</ul>
<ElImageViewer
v-if="imagePreview.visible"
:url-list="imagePreview.urls"
hide-on-click-modal
@close="handleClosePreview"
/>
</div>
</template>
<style scoped lang="scss">
.business-attachment-uploader {
display: flex;
flex-direction: column;
gap: 8px;
}
.business-attachment-uploader__trigger {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.business-attachment-uploader__hint {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__hint-icon {
color: rgb(100 116 139 / 88%);
cursor: help;
font-size: 14px;
}
.business-attachment-uploader__hint-tooltip {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 320px;
line-height: 1.5;
}
.business-attachment-uploader__hint-tooltip-ext {
word-break: break-all;
opacity: 0.85;
}
.business-attachment-uploader__empty {
color: rgb(100 116 139 / 88%);
font-size: 13px;
}
.business-attachment-uploader__input {
display: none;
}
.business-attachment-uploader__list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-blank);
font-size: 13px;
&--pending {
background: var(--el-fill-color-light);
color: rgb(100 116 139 / 88%);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
justify-content: flex-start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__status {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__more-row {
display: flex;
align-items: center;
padding: 2px 0;
}
.business-attachment-uploader__more {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
</style>
<style lang="scss">
// 浮层非 scopedpopper 渲染到 body
.business-attachment-uploader__popover {
padding: 8px 4px !important;
.business-attachment-uploader__popover-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
&:hover {
background: var(--el-fill-color-light);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'; import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
import '@wangeditor/editor/dist/css/style.css'; import '@wangeditor/editor/dist/css/style.css';
import { ElImageViewer } from 'element-plus';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'; import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'; import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { uploadFile } from '@/service/api/file'; import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessRichTextEditor' }); defineOptions({ name: 'BusinessRichTextEditor' });
@@ -28,6 +29,140 @@ const props = withDefaults(defineProps<Props>(), {
const model = defineModel<string | null | undefined>({ default: '' }); const model = defineModel<string | null | undefined>({ default: '' });
const editorRef = shallowRef<IDomEditor>(); const editorRef = shallowRef<IDomEditor>();
const containerRef = ref<HTMLElement>();
/**
* 图片预览:
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
* - 点按钮 → ElImageViewer 多图模式url-list = 当前 HTML 里所有 img src按出现顺序去重
* - 编辑态与 disabled 只读态共用
*/
const zoomBtnVisible = ref(false);
const zoomBtnStyle = ref<Record<string, string>>({});
const hoveredImageSrc = ref('');
const viewerVisible = ref(false);
const viewerUrlList = ref<string[]>([]);
const viewerIndex = ref(0);
let hideZoomBtnTimer: number | undefined;
function cancelHideZoomBtn() {
if (hideZoomBtnTimer !== undefined) {
window.clearTimeout(hideZoomBtnTimer);
hideZoomBtnTimer = undefined;
}
}
function scheduleHideZoomBtn() {
cancelHideZoomBtn();
hideZoomBtnTimer = window.setTimeout(() => {
zoomBtnVisible.value = false;
}, 150);
}
function positionZoomBtn(img: HTMLImageElement) {
const container = containerRef.value;
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
const btnSize = 28;
const gap = 8;
zoomBtnStyle.value = {
top: `${imgRect.top - containerRect.top + gap}px`,
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
};
hoveredImageSrc.value = img.getAttribute('src') ?? '';
zoomBtnVisible.value = true;
}
function isZoomBtn(el: EventTarget | null): boolean {
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
}
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
const container = containerRef.value;
if (!container) {
return null;
}
const target = e.target as HTMLElement | null;
// 1) target 本身或祖先链上是 img
const direct =
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
if (direct && container.contains(direct)) {
return direct;
}
// 2) 兜底wangeditor 可能在图片上层叠了 resize/selection 遮罩target 不是 img用坐标穿透找
if (typeof document.elementsFromPoint === 'function') {
const stack = document.elementsFromPoint(e.clientX, e.clientY);
for (const el of stack) {
if (el.tagName === 'IMG' && container.contains(el)) {
return el as HTMLImageElement;
}
}
}
return null;
}
function onContainerMouseOver(e: MouseEvent) {
if (isZoomBtn(e.target)) {
cancelHideZoomBtn();
return;
}
const img = findImageAtPoint(e);
if (img) {
cancelHideZoomBtn();
positionZoomBtn(img);
} else {
scheduleHideZoomBtn();
}
}
function onContainerMouseLeave() {
scheduleHideZoomBtn();
}
function onTextScroll() {
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
zoomBtnVisible.value = false;
}
function openImageViewer() {
if (!hoveredImageSrc.value) {
return;
}
const urls = listImageSrcs(model.value);
const idx = urls.indexOf(hoveredImageSrc.value);
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
viewerIndex.value = idx >= 0 ? idx : 0;
viewerVisible.value = true;
}
function closeImageViewer() {
viewerVisible.value = false;
}
/**
* 会话级清理账本(富文本图片治标):
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
* - committed: commit() 调用后置 true阻止后续 rollback / 卸载兜底重复删
*
* 真删时机:
* - commit(): 扫当前 model HTML删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
*/
interface RichTextSession {
uploadedMap: Map<string, string>;
committed: boolean;
}
const session = reactive<RichTextSession>({
uploadedMap: new Map(),
committed: false
});
const toolbarConfig: Partial<IToolbarConfig> = { const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: [ excludeKeys: [
@@ -63,8 +198,12 @@ const editorConfig: Partial<IEditorConfig> = {
return; return;
} }
const url = result.data; // 用永久代理路径塞 <img src>,不要用 result.data.url24h 签名会过期)
insertFn(url, file.name, url); const { id, configId, path } = result.data;
const proxyUrl = buildFileProxyUrl(configId, path);
// 记录 url -> fileId后续 commit/rollback 才知道删哪个
session.uploadedMap.set(proxyUrl, id);
insertFn(proxyUrl, file.name, proxyUrl);
} }
} }
} }
@@ -88,9 +227,116 @@ watch(
function handleCreated(editor: IDomEditor) { function handleCreated(editor: IDomEditor) {
editorRef.value = editor; editorRef.value = editor;
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
} }
/**
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
*/
function extractImageUrls(html: string | null | undefined): Set<string> {
const urls = new Set<string>();
if (!html) {
return urls;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
urls.add(match[1]);
match = re.exec(html);
}
return urls;
}
/** 按出现顺序去重列出当前 HTML 内所有 img src给 ElImageViewer 用。 */
function listImageSrcs(html: string | null | undefined): string[] {
const list: string[] = [];
if (!html) {
return list;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
if (!list.includes(match[1])) {
list.push(match[1]);
}
match = re.exec(html);
}
return list;
}
/** 删除一批 fileId。fire-and-forget单项失败仅 console.warn。 */
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
}
})
);
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 清空 uploadedMap 并重置 committedHTML 里已有的图(编辑模式回显的)不进 uploadedMap
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
*/
initSession() {
session.uploadedMap.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 扫当前 model HTMLuploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
*/
async commit() {
const currentUrls = extractImageUrls(model.value);
const toDelete: string[] = [];
session.uploadedMap.forEach((fileId, url) => {
if (!currentUrls.has(url)) {
toDelete.push(fileId);
}
});
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 删 uploadedMap 里所有项(整个会话回滚)。
*/
async rollback() {
if (session.committed) {
return;
}
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
}
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelHideZoomBtn();
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.removeEventListener('scroll', onTextScroll);
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
if (!session.committed) {
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(toDelete);
}
editorRef.value?.destroy(); editorRef.value?.destroy();
editorRef.value = undefined; editorRef.value = undefined;
}); });
@@ -116,7 +362,7 @@ const editorStyle = computed(() => {
</script> </script>
<template> <template>
<div :class="containerClass"> <div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
<Toolbar <Toolbar
class="business-rich-text-editor__toolbar" class="business-rich-text-editor__toolbar"
:editor="editorRef" :editor="editorRef"
@@ -131,11 +377,36 @@ const editorStyle = computed(() => {
mode="default" mode="default"
@on-created="handleCreated" @on-created="handleCreated"
/> />
<button
v-show="zoomBtnVisible"
type="button"
class="business-rich-text-editor__zoom-btn"
:style="zoomBtnStyle"
title="预览图片"
aria-label="预览图片"
@click.stop="openImageViewer"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
/>
</svg>
</button>
<ElImageViewer
v-if="viewerVisible"
:url-list="viewerUrlList"
:initial-index="viewerIndex"
:z-index="3100"
teleported
hide-on-click-modal
@close="closeImageViewer"
/>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.business-rich-text-editor { .business-rich-text-editor {
position: relative;
width: 100%; width: 100%;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base); border-radius: var(--el-border-radius-base);
@@ -157,6 +428,27 @@ const editorStyle = computed(() => {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
&__zoom-btn {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.75);
}
}
} }
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */ /* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */

View File

@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
<template> <template>
<div class="business-rich-text-view"> <div class="business-rich-text-view">
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span> <span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-else class="business-rich-text-view__content" v-html="safeHtml" /> <div v-else class="business-rich-text-view__content" v-html="safeHtml" />
</div> </div>
</template> </template>

View File

@@ -1,12 +1,13 @@
import { computed, defineComponent, ref } from 'vue'; import { computed, defineComponent, h, ref } from 'vue';
import type { PropType } from 'vue'; import type { Component, PropType } from 'vue';
import { ElButton, ElPopover } from 'element-plus'; import { ElButton, ElPopover, ElTooltip } from 'element-plus';
import { $t } from '@/locales'; import { $t } from '@/locales';
export type BusinessTableAction = { export type BusinessTableAction = {
key: string; key: string;
label: string; label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
icon?: Component;
disabled?: boolean; disabled?: boolean;
onClick: () => void | Promise<void>; onClick: () => void | Promise<void>;
}; };
@@ -17,12 +18,20 @@ export default defineComponent({
actions: { actions: {
type: Array as PropType<BusinessTableAction[]>, type: Array as PropType<BusinessTableAction[]>,
required: true required: true
},
variant: {
type: String as PropType<'button' | 'icon'>,
default: 'button'
} }
}, },
setup(props) { setup(props) {
const popoverVisible = ref(false); const popoverVisible = ref(false);
const directActions = computed(() => { const directActions = computed(() => {
if (props.variant === 'icon') {
return props.actions;
}
if (props.actions.length <= 2) { if (props.actions.length <= 2) {
return props.actions; return props.actions;
} }
@@ -31,6 +40,10 @@ export default defineComponent({
}); });
const moreActions = computed(() => { const moreActions = computed(() => {
if (props.variant === 'icon') {
return [];
}
if (props.actions.length <= 2) { if (props.actions.length <= 2) {
return []; return [];
} }
@@ -47,9 +60,14 @@ export default defineComponent({
await action.onClick(); await action.onClick();
} }
return () => ( function renderIcon(action: BusinessTableAction) {
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}> if (!action.icon) return null;
{directActions.value.map(action => (
return h(action.icon, { class: 'business-table-action-icon' });
}
function renderButtonAction(action: BusinessTableAction) {
return (
<ElButton <ElButton
key={action.key} key={action.key}
plain plain
@@ -61,7 +79,67 @@ export default defineComponent({
> >
{action.label} {action.label}
</ElButton> </ElButton>
))} );
}
function renderIconAction(action: BusinessTableAction) {
return (
<ElTooltip key={action.key} content={action.label} placement="top">
<ElButton
link
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-icon-button"
aria-label={action.label}
onClick={() => handleAction(action)}
>
{renderIcon(action)}
</ElButton>
</ElTooltip>
);
}
function renderMenuButton(action: BusinessTableAction) {
if (props.variant === 'icon') {
return (
<ElButton
key={action.key}
link
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-menu__link"
onClick={() => handleAction(action)}
>
<span class="business-table-action-menu__item">
{renderIcon(action)}
<span>{action.label}</span>
</span>
</ElButton>
);
}
return (
<ElButton
key={action.key}
plain
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-menu__button"
onClick={() => handleAction(action)}
>
{action.label}
</ElButton>
);
}
return () => (
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
{directActions.value.map(action =>
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
)}
{moreActions.value.length > 0 && ( {moreActions.value.length > 0 && (
<ElPopover <ElPopover
@@ -74,32 +152,28 @@ export default defineComponent({
{{ {{
reference: () => ( reference: () => (
<ElButton <ElButton
plain link={props.variant === 'icon'}
plain={props.variant !== 'icon'}
size="small" size="small"
class="business-table-action-button" class={
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
}
aria-label={$t('common.more')}
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
{props.variant === 'icon' ? (
<icon-mdi-dots-horizontal class="business-table-action-icon" />
) : (
<span class="inline-flex items-center gap-4px"> <span class="inline-flex items-center gap-4px">
{$t('common.more')} {$t('common.more')}
<icon-mdi-chevron-down class="text-14px" /> <icon-mdi-chevron-down class="text-14px" />
</span> </span>
)}
</ElButton> </ElButton>
), ),
default: () => ( default: () => (
<div class="business-table-action-menu"> <div class="business-table-action-menu">
{moreActions.value.map(action => ( {moreActions.value.map(action => renderMenuButton(action))}
<ElButton
key={action.key}
plain
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-menu__button"
onClick={() => handleAction(action)}
>
{action.label}
</ElButton>
))}
</div> </div>
) )
}} }}

View File

@@ -0,0 +1,938 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useFormItem } from 'element-plus';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
import { useChainSource } from './business-user-picker/composables/use-chain-source';
import UserPickerTrigger from './business-user-picker/components/user-picker-trigger.vue';
import IconEpOfficeBuilding from '~icons/ep/office-building';
import IconEpUser from '~icons/ep/user';
defineOptions({ name: 'BusinessUserPicker' });
type Source = 'dept' | 'chain' | 'all';
interface Props {
userOptions: Api.SystemManage.UserSimple[];
sources?: Source[];
multiple?: boolean;
disabledUserIds?: readonly string[];
excludeUserIds?: readonly string[];
disabledLabel?: string;
placeholder?: string;
title?: string;
dialogWidth?: string;
confirmText?: string;
triggerSize?: 'default' | 'small' | 'large';
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
sources: () => ['dept', 'chain', 'all'],
multiple: false,
disabledUserIds: () => [],
excludeUserIds: () => [],
disabledLabel: '',
placeholder: '请选择用户',
title: '选择用户',
dialogWidth: '820px',
confirmText: '',
triggerSize: 'default',
disabled: false
});
interface Emits {
(e: 'change', value: string | string[] | null): void;
(e: 'confirm', payload: { userIds: string[] }): void;
(e: 'cancel'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<string | string[] | null>({ default: null });
const visible = defineModel<boolean>('visible', { default: false });
const { formItem } = useFormItem();
const source = ref<Source>(props.sources[0] ?? 'all');
const currentNodeId = ref<string | null>(null);
const treeSearch = ref('');
const userSearch = ref('');
const hideAdded = ref(false);
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(String)));
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(String)));
const selection = usePickerSelection(() => ({ multiple: props.multiple }));
const deptSource = useDeptSource(
() => props.userOptions,
() => new Set(selection.selectedIds.value),
() => disabledUserIdSet.value
);
const chainSource = useChainSource(
() => new Set(selection.selectedIds.value),
() => disabledUserIdSet.value
);
const showTabs = computed(() => props.sources.length > 1);
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
const committedIds = computed<string[]>(() => {
const value = model.value;
if (Array.isArray(value)) {
return value.map(String);
}
if (typeof value === 'string' && value) {
return [value];
}
return [];
});
const selectedUsers = computed(() =>
committedIds.value.map(id => userByIdMap.value.get(id)).filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
);
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
const visibleSelectedIds = computed(() => selection.selectedIds.value.slice(0, 4));
const overflowSelectedCount = computed(() => Math.max(0, selection.size.value - 4));
const overflowSelectedIds = computed(() => selection.selectedIds.value.slice(4));
const overflowPopoverVisible = ref(false);
const overflowReferenceEl = ref<HTMLElement | null>(null);
function handleOverflowOutsideClick(e: MouseEvent) {
if (!overflowPopoverVisible.value) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('.user-picker__overflow-popper')) return;
if (target.closest('.el-popper')) return;
if (overflowReferenceEl.value?.contains(target)) return;
overflowPopoverVisible.value = false;
}
onMounted(() => document.addEventListener('mousedown', handleOverflowOutsideClick, true));
onBeforeUnmount(() => document.removeEventListener('mousedown', handleOverflowOutsideClick, true));
function getUserById(uid: string) {
return userByIdMap.value.get(uid);
}
function visibleUserIds(): string[] {
let pool: string[];
if (source.value === 'all' || !currentNodeId.value) {
pool = props.userOptions.map(u => String(u.id));
} else if (source.value === 'dept') {
const node = deptSource.findNode(deptSource.tree.value, currentNodeId.value);
pool = node ? deptSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
} else {
const node = chainSource.findNode(chainSource.tree.value, currentNodeId.value);
pool = node ? chainSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
}
return pool.filter(id => !excludeUserIdSet.value.has(id));
}
const filteredUserIds = computed(() => {
let ids = visibleUserIds();
if (hideAdded.value) ids = ids.filter(id => !disabledUserIdSet.value.has(id));
const kw = userSearch.value.trim().toLowerCase();
if (kw) {
ids = ids.filter(id => {
const u = getUserById(id);
if (!u) return false;
return (
u.nickname.toLowerCase().includes(kw) ||
(u.username ?? '').toLowerCase().includes(kw) ||
(u.deptName ?? '').toLowerCase().includes(kw)
);
});
}
return ids;
});
async function switchSource(next: Source) {
if (source.value === next) return;
source.value = next;
currentNodeId.value = null;
treeSearch.value = '';
if (next === 'dept') await deptSource.ensureLoaded();
else if (next === 'chain') await chainSource.ensureLoaded();
}
function handleDeptNodeClick(data: Api.SystemManage.DeptSimple) {
currentNodeId.value = deptSource.nodeKey(data);
}
function handleChainNodeClick(data: Api.SystemManage.UserManagementRelationTreeRespVO) {
currentNodeId.value = chainSource.nodeKey(data);
}
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
if (!props.multiple) return;
const ids = deptSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
const state = deptSource.getNodeCheckState(node);
if (state === 'all') selection.removeMany(ids);
else selection.addMany(ids);
}
function toggleChainCheck(node: Api.SystemManage.UserManagementRelationTreeRespVO) {
if (!props.multiple) return;
const ids = chainSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
const state = chainSource.getNodeCheckState(node);
if (state === 'all') selection.removeMany(ids);
else selection.addMany(ids);
}
function toggleUser(uid: string) {
if (disabledUserIdSet.value.has(uid)) return;
selection.toggle(uid);
}
function clearAll() {
selection.clear(lockedSelectedIds.value);
}
function clearUserFilter() {
userSearch.value = '';
hideAdded.value = false;
}
const confirmDisabled = computed(() => {
if (!props.multiple) return !selection.selectedIds.value.length;
return selection.size.value === 0;
});
const resolvedConfirmText = computed(() => {
if (props.confirmText) return props.confirmText;
if (!props.multiple) return '确定';
return `确定(${selection.size.value})`;
});
function handleConfirm() {
if (confirmDisabled.value) return;
const value = selection.commit();
model.value = value;
emit('change', value);
emit('confirm', { userIds: selection.selectedIds.value });
visible.value = false;
nextTick(() => {
formItem?.validate?.('change').catch(() => {});
});
}
function handleCancel() {
emit('cancel');
visible.value = false;
}
function openDialog() {
visible.value = true;
}
watch(visible, async value => {
if (value) {
treeSearch.value = '';
userSearch.value = '';
hideAdded.value = false;
currentNodeId.value = null;
source.value = props.sources[0] ?? 'all';
selection.reset(model.value);
if (source.value === 'dept') await deptSource.ensureLoaded();
else if (source.value === 'chain') await chainSource.ensureLoaded();
await nextTick();
}
});
</script>
<template>
<div class="business-user-picker">
<slot name="trigger" :open="openDialog" :selected-users="selectedUsers" :disabled="disabled">
<UserPickerTrigger
:selected-users="selectedUsers"
:placeholder="placeholder"
:multiple="multiple"
:disabled="disabled"
:size="triggerSize"
@open="openDialog"
/>
</slot>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:width="dialogWidth"
max-body-height="540px"
:confirm-disabled="confirmDisabled"
:confirm-text="resolvedConfirmText"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<div class="user-picker">
<div v-if="showTabs" class="user-picker__tabs">
<button
v-for="tab in sources"
:key="tab"
class="user-picker__tab"
:class="{ 'is-active': source === tab }"
type="button"
@click="switchSource(tab)"
>
{{ tab === 'dept' ? '部门' : tab === 'chain' ? '团队' : '全部用户' }}
</button>
</div>
<div class="user-picker__picker" :class="{ 'is-single': source === 'all' }">
<div v-if="source !== 'all'" class="user-picker__col user-picker__col--tree">
<div class="user-picker__col-head">{{ source === 'dept' ? '部门' : '团队' }}</div>
<div class="user-picker__search">
<ElInput
v-model="treeSearch"
size="small"
clearable
:placeholder="source === 'dept' ? '搜索部门…' : '搜索成员…'"
/>
</div>
<div
v-loading="source === 'dept' ? deptSource.loading.value : chainSource.loading.value"
class="user-picker__col-body"
>
<ElTree
v-if="source === 'dept'"
:data="deptSource.filterByKeyword(treeSearch)"
:props="deptSource.treeProps.value"
node-key="id"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="user-picker__tree"
@node-click="handleDeptNodeClick"
>
<template #default="{ data }">
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === String(data.id) }">
<span
v-if="multiple"
class="user-picker__node-check"
:class="{
'is-checked': deptSource.getNodeCheckState(data) === 'all',
'is-partial': deptSource.getNodeCheckState(data) === 'partial'
}"
@click.stop="toggleDeptCheck(data)"
/>
<IconEpOfficeBuilding class="user-picker__node-icon" />
<span class="user-picker__node-label">{{ data.name }}</span>
<span v-if="deptSource.getMetaText(data)" class="user-picker__node-meta">
{{ deptSource.getMetaText(data) }}
</span>
</div>
</template>
</ElTree>
<ElTree
v-else
:data="chainSource.filterByKeyword(treeSearch)"
:props="chainSource.treeProps.value"
node-key="userId"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="user-picker__tree"
@node-click="handleChainNodeClick"
>
<template #default="{ data }">
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === chainSource.nodeKey(data) }">
<span
v-if="multiple"
class="user-picker__node-check"
:class="{
'is-checked': chainSource.getNodeCheckState(data) === 'all',
'is-partial': chainSource.getNodeCheckState(data) === 'partial'
}"
@click.stop="toggleChainCheck(data)"
/>
<IconEpUser class="user-picker__node-icon" />
<span class="user-picker__node-label">{{ data.userNickname }}</span>
<span v-if="chainSource.getMetaText(data)" class="user-picker__node-meta">
{{ chainSource.getMetaText(data) }}
</span>
</div>
</template>
</ElTree>
</div>
</div>
<div class="user-picker__col user-picker__col--users">
<div class="user-picker__col-head user-picker__col-head--user">
<span>
候选用户(
<span>{{ filteredUserIds.length }}</span>
)
</span>
<label v-if="multiple" class="user-picker__hide-added">
<ElCheckbox v-model="hideAdded">隐藏已添加</ElCheckbox>
</label>
</div>
<div class="user-picker__search">
<ElInput
v-model="userSearch"
size="small"
clearable
:placeholder="source === 'all' ? '搜索用户名 / 部门…' : '搜索用户名…'"
/>
</div>
<div class="user-picker__col-body">
<div v-if="!filteredUserIds.length" class="user-picker__empty">
该节点下没有匹配用户
<button
v-if="userSearch || hideAdded"
type="button"
class="user-picker__link user-picker__empty-action"
@click="clearUserFilter"
>
清除筛选条件
</button>
</div>
<div
v-for="uid in filteredUserIds"
:key="uid"
class="user-picker__user-row"
:class="{
'is-disabled': disabledUserIdSet.has(uid),
'is-selected': !multiple && selection.has(uid)
}"
@click="toggleUser(uid)"
>
<span v-if="multiple" class="user-picker__node-check" :class="{ 'is-checked': selection.has(uid) }" />
<span class="user-picker__user-avatar">{{ (getUserById(uid)?.nickname ?? '?').slice(0, 1) }}</span>
<div class="user-picker__user-main">
<div class="user-picker__user-name">{{ getUserById(uid)?.nickname }}</div>
</div>
<span v-if="disabledUserIdSet.has(uid) && disabledLabel" class="user-picker__user-tag">
{{ disabledLabel }}
</span>
</div>
</div>
</div>
</div>
<div v-if="multiple" class="user-picker__selected">
<div class="user-picker__selected-head">
<span>
已选
<strong>{{ selection.size.value }}</strong>
</span>
<button
v-if="selection.size.value > lockedSelectedIds.length"
type="button"
class="user-picker__link user-picker__link--danger"
@click="clearAll"
>
清空
</button>
</div>
<div v-if="selection.size.value === 0" class="user-picker__selected-empty">从左侧勾选用户后会出现在这里</div>
<div v-else class="user-picker__chips">
<span v-for="uid in visibleSelectedIds" :key="uid" class="user-picker__chip">
<span class="user-picker__chip-name">
{{ getUserById(uid)?.nickname }}
<ElTooltip v-if="disabledUserIdSet.has(uid) && disabledLabel" :content="disabledLabel" placement="top">
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
</ElTooltip>
</span>
<button
v-if="!disabledUserIdSet.has(uid)"
type="button"
class="user-picker__chip-x"
@click="toggleUser(uid)"
>
×
</button>
</span>
<ElPopover
v-if="overflowSelectedCount > 0"
:visible="overflowPopoverVisible"
placement="top-end"
:width="360"
popper-class="user-picker__overflow-popper"
>
<template #reference>
<button
ref="overflowReferenceEl"
type="button"
class="user-picker__chip-more"
@click="overflowPopoverVisible = !overflowPopoverVisible"
>
+{{ overflowSelectedCount }} 更多
</button>
</template>
<div class="user-picker__overflow-head">
<span>
另外
<strong>{{ overflowSelectedCount }}</strong>
</span>
</div>
<div class="user-picker__overflow-chips">
<span v-for="uid in overflowSelectedIds" :key="uid" class="user-picker__chip">
<span class="user-picker__chip-name">
{{ getUserById(uid)?.nickname }}
<ElTooltip
v-if="disabledUserIdSet.has(uid) && disabledLabel"
:content="disabledLabel"
placement="top"
>
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
</ElTooltip>
</span>
<button
v-if="!disabledUserIdSet.has(uid)"
type="button"
class="user-picker__chip-x"
@click="toggleUser(uid)"
>
×
</button>
</span>
</div>
</ElPopover>
</div>
</div>
</div>
</BusinessFormDialog>
</div>
</template>
<style scoped>
.business-user-picker {
display: block;
width: 100%;
}
/* picker 内容上下贴满,标准 body padding 显得空——仅在含本组件的 dialog 上收紧 */
:deep(.business-form-dialog__body:has(.user-picker)) {
padding-top: 8px !important;
padding-bottom: 8px !important;
}
.user-picker {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-picker__tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--el-border-color);
}
.user-picker__tab {
padding: 6px 14px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12.5px;
color: var(--el-text-color-regular);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.user-picker__tab.is-active {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
font-weight: 600;
}
.user-picker__picker {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
height: min(280px, 44vh);
min-height: 260px;
}
.user-picker__picker.is-single {
grid-template-columns: 1fr;
}
.user-picker__col {
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.user-picker__col-head {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
background: #fafbfc;
font-size: 12px;
color: var(--el-text-color-regular);
}
.user-picker__col-head--user {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-picker__col-body {
flex: 1;
overflow-y: auto;
}
.user-picker__search {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
}
.user-picker__tree {
padding: 4px;
background: transparent;
}
.user-picker__tree :deep(.el-tree-node__content) {
height: 32px;
padding-right: 8px !important;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.user-picker__tree :deep(.el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
.user-picker__tree :deep(.el-tree-node__expand-icon) {
padding: 4px;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
.user-picker__tree :deep(.el-tree-node__expand-icon.is-leaf) {
color: transparent;
}
.user-picker__node {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 100%;
font-size: 13px;
color: var(--el-text-color-primary);
}
.user-picker__node.is-active {
color: var(--el-color-primary);
font-weight: 500;
}
.user-picker__node-check {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1px solid var(--el-border-color);
border-radius: 2px;
background: var(--el-bg-color);
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker__node-check:hover {
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-checked {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-checked::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
border: solid #fff;
border-width: 0 1px 1px 0;
transform: rotate(45deg);
}
.user-picker__node-check.is-partial {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-partial::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 2px;
margin: -1px 0 0 -4px;
background: #fff;
border-radius: 1px;
}
.user-picker__node-icon {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
transition: color 0.15s ease;
}
.user-picker__node.is-active .user-picker__node-icon {
color: var(--el-color-primary);
}
.user-picker__node-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker__node-meta {
flex-shrink: 0;
padding-left: 6px;
font-size: 12px;
color: var(--el-text-color-placeholder);
font-variant-numeric: tabular-nums;
}
.user-picker__node.is-active .user-picker__node-meta {
color: var(--el-color-primary);
opacity: 0.7;
}
.user-picker__user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px;
height: 36px;
border-bottom: 1px solid var(--el-border-color-lighter);
cursor: pointer;
}
.user-picker__user-row:hover {
background: var(--el-fill-color);
}
.user-picker__user-row.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.user-picker__user-row.is-disabled:hover {
background: transparent;
}
.user-picker__user-row.is-selected {
background: var(--el-color-primary-light-9);
}
.user-picker__user-row.is-selected .user-picker__user-name {
color: var(--el-color-primary);
font-weight: 500;
}
.user-picker__user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.user-picker__user-main {
flex: 1;
min-width: 0;
overflow: hidden;
}
.user-picker__user-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker__user-tag {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
background: var(--el-color-warning-light-7);
color: var(--el-color-warning-dark-2);
}
.user-picker__empty {
padding: 40px 0;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.user-picker__hide-added {
font-size: 11.5px;
}
.user-picker__empty-action {
display: block;
margin: 6px auto 0;
}
.user-picker__selected {
padding: 8px 12px;
background: #f8fafc;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.user-picker__selected-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 11.5px;
color: var(--el-text-color-regular);
}
.user-picker__selected-head strong {
color: var(--el-color-primary);
font-weight: 700;
font-size: 12.5px;
}
.user-picker__selected-empty {
display: flex;
align-items: center;
min-height: 26px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.user-picker__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 26px;
}
.user-picker__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 8px;
background: #fff;
border: 1px solid var(--el-border-color-darker);
border-radius: 999px;
font-size: 11.5px;
}
.user-picker__chip-name {
display: inline-flex;
align-items: center;
gap: 2px;
}
.user-picker__chip-lock {
color: var(--el-color-warning-dark-2);
font-size: 11px;
}
.user-picker__chip-x {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
border: none;
cursor: pointer;
font-size: 11px;
}
.user-picker__chip-x:hover {
background: var(--el-color-danger);
color: #fff;
}
.user-picker__chip-more {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px dashed var(--el-border-color-darker);
background: transparent;
color: var(--el-color-primary);
font-size: 11.5px;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker__chip-more:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.user-picker__overflow-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.user-picker__overflow-head strong {
color: var(--el-color-primary);
font-weight: 700;
}
.user-picker__overflow-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 260px;
overflow-y: auto;
}
.user-picker__link {
background: transparent;
border: none;
cursor: pointer;
font-size: 11.5px;
padding: 0;
}
.user-picker__link--danger {
color: var(--el-color-danger);
}
.user-picker__link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'UserPickerTrigger' });
interface Props {
selectedUsers: Api.SystemManage.UserSimple[];
placeholder: string;
multiple: boolean;
disabled: boolean;
size: 'default' | 'small' | 'large';
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'open'): void }>();
const displayText = computed(() => {
if (!props.selectedUsers.length) return '';
if (!props.multiple) return props.selectedUsers[0]?.nickname ?? '';
const head = props.selectedUsers
.slice(0, 2)
.map(u => u.nickname)
.join('、');
const rest = props.selectedUsers.length - 2;
return rest > 0 ? `${head} +${rest}` : head;
});
const sizeClass = computed(() => `is-${props.size}`);
function handleClick() {
if (props.disabled) return;
emit('open');
}
</script>
<template>
<div
class="user-picker-trigger"
:class="[sizeClass, { 'is-disabled': disabled }]"
role="button"
tabindex="0"
@click="handleClick"
@keydown.enter.prevent="handleClick"
@keydown.space.prevent="handleClick"
>
<span v-if="displayText" class="user-picker-trigger__text">{{ displayText }}</span>
<span v-else class="user-picker-trigger__placeholder">{{ placeholder }}</span>
<span class="user-picker-trigger__suffix">
<icon-ep:arrow-down />
</span>
</div>
</template>
<style scoped>
.user-picker-trigger {
display: inline-flex;
align-items: center;
width: 100%;
min-height: 32px;
padding: 0 30px 0 11px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
cursor: pointer;
position: relative;
box-sizing: border-box;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker-trigger.is-small {
min-height: 24px;
font-size: 12px;
}
.user-picker-trigger.is-large {
min-height: 40px;
font-size: 14px;
}
.user-picker-trigger:hover:not(.is-disabled) {
border-color: var(--el-border-color-hover);
}
.user-picker-trigger:focus-visible {
outline: none;
border-color: var(--el-color-primary);
}
.user-picker-trigger.is-disabled {
background: var(--el-disabled-bg-color);
color: var(--el-disabled-text-color);
cursor: not-allowed;
border-color: var(--el-border-color);
}
.user-picker-trigger__text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-trigger__placeholder {
flex: 1;
min-width: 0;
color: var(--el-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-trigger__suffix {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
color: var(--el-text-color-placeholder);
font-size: 14px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,90 @@
import { computed, ref } from 'vue';
import { fetchGetUserManagementRelationTree } from '@/service/api';
import type { TreeCheckState } from './use-dept-source';
type ChainNode = Api.SystemManage.UserManagementRelationTreeRespVO;
export function useChainSource(selectedIds: () => Set<string>, disabledUserIdSet: () => Set<string>) {
const tree = ref<ChainNode[]>([]);
const loading = ref(false);
let loaded = false;
async function ensureLoaded() {
if (loaded) return;
loading.value = true;
try {
const { data } = await fetchGetUserManagementRelationTree({ fromUserIndex: false });
tree.value = data ?? [];
loaded = true;
} finally {
loading.value = false;
}
}
function nodeKey(node: ChainNode): string {
return node.id ?? `chain_${node.userId}`;
}
function getNodeUserIds(node: ChainNode): string[] {
const ids = new Set<string>([String(node.userId)]);
if (node.children) {
for (const c of node.children) {
for (const id of getNodeUserIds(c)) ids.add(id);
}
}
return [...ids];
}
function getNodeCheckState(node: ChainNode): TreeCheckState {
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
if (!ids.length) return 'none';
const sel = ids.filter(id => selectedIds().has(id)).length;
if (sel === 0) return 'none';
if (sel === ids.length) return 'all';
return 'partial';
}
function findNode(list: ChainNode[], key: string): ChainNode | null {
for (const n of list) {
if (nodeKey(n) === key) return n;
if (n.children) {
const r = findNode(n.children, key);
if (r) return r;
}
}
return null;
}
function matchKeyword(node: ChainNode, kw: string): boolean {
if (!kw) return true;
if (node.userNickname.toLowerCase().includes(kw)) return true;
if (node.children) return node.children.some(c => matchKeyword(c, kw));
return false;
}
function filterByKeyword(kw: string) {
const lower = kw.trim().toLowerCase();
if (!lower) return tree.value;
return tree.value.filter(n => matchKeyword(n, lower));
}
function getMetaText(node: ChainNode): string {
const total = getNodeUserIds(node).length;
return total > 1 ? `${total}` : '';
}
const treeProps = computed(() => ({ children: 'children', label: 'userNickname' }) as const);
return {
tree,
loading,
treeProps,
ensureLoaded,
getNodeUserIds,
getNodeCheckState,
findNode,
filterByKeyword,
getMetaText,
nodeKey
};
}

View File

@@ -0,0 +1,99 @@
import { computed, ref } from 'vue';
import { fetchGetDeptSimpleList } from '@/service/api';
import { buildMenuTree } from '@/views/system/shared/menu-tree';
export type TreeCheckState = 'none' | 'partial' | 'all';
export function useDeptSource(
userOptions: () => Api.SystemManage.UserSimple[],
selectedIds: () => Set<string>,
disabledUserIdSet: () => Set<string>
) {
const tree = ref<Api.SystemManage.DeptSimple[]>([]);
const loading = ref(false);
let loaded = false;
async function ensureLoaded() {
if (loaded) return;
loading.value = true;
try {
const { data } = await fetchGetDeptSimpleList();
tree.value = data ? buildMenuTree(data) : [];
loaded = true;
} finally {
loading.value = false;
}
}
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
const ids: string[] = [String(node.id)];
if (node.children) {
for (const c of node.children) ids.push(...collectDeptIds(c));
}
return ids;
}
function getNodeUserIds(node: Api.SystemManage.DeptSimple): string[] {
const deptIds = new Set(collectDeptIds(node));
return userOptions()
.filter(u => u.deptId !== null && u.deptId !== undefined && deptIds.has(String(u.deptId)))
.map(u => String(u.id));
}
function getNodeCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
if (!ids.length) return 'none';
const sel = ids.filter(id => selectedIds().has(id)).length;
if (sel === 0) return 'none';
if (sel === ids.length) return 'all';
return 'partial';
}
function findNode(list: Api.SystemManage.DeptSimple[], key: string): Api.SystemManage.DeptSimple | null {
for (const n of list) {
if (String(n.id) === key) return n;
if (n.children) {
const r = findNode(n.children, key);
if (r) return r;
}
}
return null;
}
function matchKeyword(node: Api.SystemManage.DeptSimple, kw: string): boolean {
if (!kw) return true;
if (node.name.toLowerCase().includes(kw)) return true;
if (node.children) return node.children.some(c => matchKeyword(c, kw));
return false;
}
function filterByKeyword(kw: string) {
const lower = kw.trim().toLowerCase();
if (!lower) return tree.value;
return tree.value.filter(n => matchKeyword(n, lower));
}
function getMetaText(node: Api.SystemManage.DeptSimple): string {
const total = getNodeUserIds(node).length;
return total > 0 ? `${total}` : '';
}
function nodeKey(node: Api.SystemManage.DeptSimple): string {
return String(node.id);
}
const treeProps = computed(() => ({ children: 'children', label: 'name' }) as const);
return {
tree,
loading,
treeProps,
ensureLoaded,
getNodeUserIds,
getNodeCheckState,
findNode,
filterByKeyword,
getMetaText,
nodeKey
};
}

View File

@@ -0,0 +1,89 @@
import { computed, ref } from 'vue';
export interface PickerSelectionOptions {
multiple: boolean;
}
export function usePickerSelection(options: () => PickerSelectionOptions) {
const multiSet = ref<Set<string>>(new Set());
const singleId = ref<string | null>(null);
const multiple = computed(() => options().multiple);
function has(userId: string): boolean {
if (multiple.value) return multiSet.value.has(userId);
return singleId.value === userId;
}
function toggle(userId: string) {
if (multiple.value) {
if (multiSet.value.has(userId)) multiSet.value.delete(userId);
else multiSet.value.add(userId);
multiSet.value = new Set(multiSet.value);
} else {
singleId.value = singleId.value === userId ? null : userId;
}
}
function addMany(userIds: readonly string[]) {
if (!multiple.value) {
singleId.value = userIds[0] ?? singleId.value;
return;
}
for (const id of userIds) multiSet.value.add(id);
multiSet.value = new Set(multiSet.value);
}
function removeMany(userIds: readonly string[]) {
if (!multiple.value) {
if (singleId.value && userIds.includes(singleId.value)) singleId.value = null;
return;
}
for (const id of userIds) multiSet.value.delete(id);
multiSet.value = new Set(multiSet.value);
}
function clear(preserveIds?: readonly string[]) {
const keep = new Set((preserveIds ?? []).map(String));
if (multiple.value) {
const next = new Set<string>();
for (const id of multiSet.value) {
if (keep.has(id)) next.add(id);
}
multiSet.value = next;
} else if (singleId.value && !keep.has(singleId.value)) singleId.value = null;
}
function reset(initial: string | string[] | null | undefined) {
if (multiple.value) {
const ids = Array.isArray(initial) ? initial.map(String) : [];
multiSet.value = new Set(ids);
} else {
singleId.value = typeof initial === 'string' ? initial : null;
}
}
const selectedIds = computed<string[]>(() => {
if (multiple.value) return [...multiSet.value];
return singleId.value ? [singleId.value] : [];
});
const size = computed(() => selectedIds.value.length);
function commit(): string | string[] | null {
if (multiple.value) return [...multiSet.value];
return singleId.value;
}
return {
selectedIds,
size,
has,
toggle,
addMany,
removeMany,
clear,
reset,
commit
};
}

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, watch } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { useDict } from '@/hooks/business/dict'; import { useDict } from '@/hooks/business/dict';
defineOptions({ name: 'DictSelect' }); defineOptions({ name: 'DictSelect' });
const ensuredEmptyDictCodes = new Set<string>();
interface Props { interface Props {
dictCode: string; dictCode: string;
placeholder?: string; placeholder?: string;
@@ -14,6 +17,8 @@ interface Props {
multiple?: boolean; multiple?: boolean;
collapseTags?: boolean; collapseTags?: boolean;
collapseTagsTooltip?: boolean; collapseTagsTooltip?: boolean;
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
showRemark?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
onlyEnabled: true, onlyEnabled: true,
multiple: false, multiple: false,
collapseTags: false, collapseTags: false,
collapseTagsTooltip: false collapseTagsTooltip: false,
showRemark: false
}); });
const model = defineModel<string | number | Array<string | number> | null | undefined>({ const model = defineModel<string | number | Array<string | number> | null | undefined>({
default: undefined default: undefined
}); });
const dictStore = useDictStore();
const { enabledDictData, dictData } = useDict(() => props.dictCode); const { enabledDictData, dictData } = useDict(() => props.dictCode);
const dictOptions = computed(() => { const dictOptions = computed(() => {
const source = props.onlyEnabled ? enabledDictData.value : dictData.value; const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
return source.map(item => ({ return source.map(item => ({
label: item.label, label: item.label,
value: item.value value: item.value,
colorType: item.colorType ?? null,
remark: item.remark ?? null
})); }));
}); });
// 单选时取当前选中项的 colorType用于触发器 prefix 色块
const selectedColorType = computed<string | null>(() => {
if (props.multiple) return null;
const value = model.value;
if (value === null || value === undefined || value === '') return null;
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
});
watch(
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
async ([dictCode, optionCount, initialized, loading]) => {
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
return;
}
ensuredEmptyDictCodes.add(dictCode);
await dictStore.ensureDictData(dictCode, true);
},
{ immediate: true }
);
</script> </script>
<template> <template>
<ElSelect <ElSelect
v-model="model" v-model="model"
class="w-full" class="dict-select w-full"
:placeholder="props.placeholder" :placeholder="props.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:clearable="props.clearable" :clearable="props.clearable"
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
:collapse-tags="props.collapseTags" :collapse-tags="props.collapseTags"
:collapse-tags-tooltip="props.collapseTagsTooltip" :collapse-tags-tooltip="props.collapseTagsTooltip"
> >
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" /> <template v-if="selectedColorType" #prefix>
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
</template>
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
<span class="dict-select__option">
<span
v-if="item.colorType"
class="dict-select__color-dot dict-select__color-dot--option"
:style="{ background: item.colorType }"
/>
<span class="dict-select__option-label">{{ item.label }}</span>
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
</span>
</ElOption>
</ElSelect> </ElSelect>
</template> </template>
<style scoped></style> <style scoped>
.dict-select__color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
vertical-align: middle;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.dict-select__color-dot--option {
margin-right: 8px;
}
.dict-select__option {
display: inline-flex;
align-items: center;
width: 100%;
gap: 8px;
}
.dict-select__option-label {
flex: 0 0 auto;
}
.dict-select__option-remark {
margin-left: auto;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
import DictText from './dict-text.vue'; import DictText from './dict-text.vue';
defineOptions({ name: 'DictTag' }); defineOptions({ name: 'DictTag' });
@@ -14,6 +16,7 @@ interface Props {
fallback?: string; fallback?: string;
separator?: string; separator?: string;
onlyEnabled?: boolean; onlyEnabled?: boolean;
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
type?: DictTagType; type?: DictTagType;
effect?: DictTagEffect; effect?: DictTagEffect;
size?: DictTagSize; size?: DictTagSize;
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
size: 'default', size: 'default',
round: false round: false
}); });
const { getItem } = useDict(() => props.dictCode);
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
const autoColorType = computed<string | null>(() => {
if (Array.isArray(props.value)) return null;
if (props.value === null || props.value === undefined || props.value === '') return null;
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
});
// props.type 优先(向后兼容);其次字典 colorTypehex都没有时回落到原生 ElTag 默认
const hexColor = computed(() => (props.type ? null : autoColorType.value));
const tagStyle = computed<Record<string, string> | null>(() => {
if (!hexColor.value) return null;
// light 效果:浅底 + 主色字 + 中浅边plain/dark 同样的色调思路,仅明度差异
const fg = hexColor.value;
if (props.effect === 'dark') {
return {
color: '#fff',
background: fg,
borderColor: fg
};
}
if (props.effect === 'plain') {
return {
color: fg,
background: 'transparent',
borderColor: `color-mix(in srgb, ${fg} 50%, white)`
};
}
// light默认
return {
color: fg,
background: `color-mix(in srgb, ${fg} 12%, white)`,
borderColor: `color-mix(in srgb, ${fg} 30%, white)`
};
});
</script> </script>
<template> <template>
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round"> <ElTag
:type="props.type"
:effect="props.effect"
:size="props.size"
:round="props.round"
:style="tagStyle ?? undefined"
>
<DictText <DictText
:dict-code="props.dictCode" :dict-code="props.dictCode"
:value="props.value" :value="props.value"

View File

@@ -2,6 +2,13 @@
import { $t } from '@/locales'; import { $t } from '@/locales';
defineOptions({ name: 'LookForward' }); defineOptions({ name: 'LookForward' });
interface Props {
title?: string;
subtitle?: string;
}
defineProps<Props>();
</script> </script>
<template> <template>
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
<SvgIcon local-icon="expectation" /> <SvgIcon local-icon="expectation" />
</div> </div>
<slot> <slot>
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3> <h3 class="text-28px text-primary font-500">{{ title ?? $t('common.lookForward') }}</h3>
</slot>
<slot name="subtitle">
<p v-if="subtitle" class="text-14px text-base-text op-65">{{ subtitle }}</p>
</slot> </slot>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { VNode } from 'vue';
import { ElButton, ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus'; import { ElButton, ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import DictSelect from './dict-select.vue'; import DictSelect from './dict-select.vue';
@@ -17,14 +18,24 @@ export interface SearchField {
label: string; label: string;
/** 字段类型 */ /** 字段类型 */
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict'; type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
/** date 字段的日期粒度 */
dateType?: 'date' | 'month';
/** dateRange 字段的日期范围粒度 */
dateRangeType?: 'daterange' | 'monthrange';
/** 日期字段提交格式 */
valueFormat?: string;
/** 占位列数,默认 1 */ /** 占位列数,默认 1 */
span?: number; span?: number;
/** select 类型的选项 */ /** select 类型的选项 */
options?: Option[]; options?: Option[];
/** dict 类型的字典编码 */ /** dict 类型的字典编码 */
dictCode?: string; dictCode?: string;
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急" */
showRemark?: boolean;
/** 占位提示文本 */ /** 占位提示文本 */
placeholder?: string; placeholder?: string;
/** select 类型的自定义选项渲染函数 */
renderOption?: (option: Option) => VNode | VNode[] | string;
} }
interface Props { interface Props {
@@ -142,28 +153,32 @@ function handleSearch() {
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
> >
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" /> <ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
<template v-if="field.renderOption" #default>
<component :is="field.renderOption(opt)" />
</template>
</ElOption>
</ElSelect> </ElSelect>
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
start-placeholder="开始日期" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
end-placeholder="结束日期" :end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<DictSelect <DictSelect
@@ -172,6 +187,7 @@ function handleSearch() {
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
</ElFormItem> </ElFormItem>
@@ -234,28 +250,32 @@ function handleSearch() {
:disabled="props.disabled" :disabled="props.disabled"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
> >
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" /> <ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
<template v-if="field.renderOption" #default>
<component :is="field.renderOption(opt)" />
</template>
</ElOption>
</ElSelect> </ElSelect>
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
start-placeholder="开始日期" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
end-placeholder="结束日期" :end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<DictSelect <DictSelect
@@ -264,6 +284,7 @@ function handleSearch() {
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
</ElFormItem> </ElFormItem>

View File

@@ -1,61 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getPaletteColorByNumber } from '@sa/color';
defineOptions({ name: 'WaveBg' });
interface Props {
/** Theme color */
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
</script>
<template>
<div class="absolute-lt z-1 size-full overflow-hidden">
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
<svg height="1337" width="1337">
<defs>
<path
id="path-1"
opacity="1"
fill-rule="evenodd"
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
/>
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
</g>
</svg>
</div>
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
<svg height="896" width="967.8852157128662">
<defs>
<path
id="path-2"
opacity="1"
fill-rule="evenodd"
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
/>
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
</g>
</svg>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -89,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
}; };
export const postTypeOptions = transformRecordToOption(postTypeRecord); export const postTypeOptions = transformRecordToOption(postTypeRecord);
/**
* 产品对象域角色编码:产品经理
*
* 用途:
* 产品创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查产品经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的产品对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PRODUCT_MANAGER_ROLE_CODE = 'product_manager';
/**
* 项目对象域角色编码:项目经理
*
* 用途:
* 项目创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查项目经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的项目对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PROJECT_MANAGER_ROLE_CODE = 'project_manager';

View File

@@ -45,10 +45,14 @@ export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type'; export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
/** /**
* 需求优先级字典编码 * 优先级字典编码
* *
* 对应业务字段:需求相关接口和页面中的 priority * 对应业务字段:
* 来源口径:产品需求文档中定义,标签包括紧急、高、中、低 * - 需求(产品需求 / 项目需求)的 priority旧口径Integer数字大=高0=低 / 3=紧急)
* - 任务 / 执行的 priority新口径String "0"~"3",数字越小优先级越高,"1"=默认 P1
*
* 来源口径:后端统一字典 rdms_req_priority4 档标签 P0/P1/P2/P3。
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
*/ */
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority'; export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
@@ -75,3 +79,43 @@ export const RDMS_PROJECT_TYPE_DICT_CODE = 'rdms_project_type';
* 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 rdms_project_execution_type * 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 rdms_project_execution_type
*/ */
export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_type'; export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_type';
/**
* 状态机对象类型字典编码
*
* 对应业务字段:状态机管理中的 objectType / 对象类型
* 来源口径:用户明确指定对象类型下拉来自运行时字典 object_status_model_object_type
*/
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
/**
* 任务/个人事项类型字典编码
*
* 对应业务字段:任务、个人事项中的 type
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task_item_type
*/
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task_item_type';
/**
* 需求允许删除的状态字典编码
*
* 对应业务字段:需求删除功能中判断 statusCode 是否允许删除
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
*/
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
/**
* 工作日志难度字典编码
*
* 对应业务字段:任务/个人事项工作日志中的 difficulty
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
*/
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班时长快捷选项字典编码
*
* 对应业务字段:加班申请中的 overtimeDuration
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';

View File

@@ -1,8 +0,0 @@
/** baidu map sdk url */
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
/** Amap sdk url */
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
/** tencent sdk url */
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';

View File

@@ -10,11 +10,16 @@ export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger
export type StatusDomain = export type StatusDomain =
| 'projectExecution' | 'projectExecution'
| 'projectTask' | 'projectTask'
| 'executionMember' | 'executionAssignee'
| 'taskAssigneeMember'
| 'project' | 'project'
| 'product' | 'product'
| 'requirement' | 'productRequirement'
| 'workOrder'; | 'projectRequirement'
| 'workOrder'
| 'workReport'
| 'personalItem'
| 'overtimeApplication';
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = { const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
// 项目-执行 // 项目-执行
@@ -29,25 +34,73 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
projectTask: { projectTask: {
pending: 'info', pending: 'info',
active: 'primary', active: 'primary',
blocked: 'warning', paused: 'warning',
completed: 'success', completed: 'success',
cancelled: 'danger' cancelled: 'danger'
}, },
// 执行成员变更事件 // 执行协办人变更事件
executionMember: { executionAssignee: {
join: 'success', join: 'success',
inactive: 'danger', inactive: 'danger',
owner_transfer_in: 'warning', owner_transfer_in: 'warning',
owner_transfer_out: 'warning' owner_transfer_out: 'warning'
}, },
// 任务协办人变更事件
taskAssigneeMember: {
join: 'success',
inactive: 'danger'
},
// 项目(待补全) // 项目(待补全)
project: {}, project: {},
// 产品(待补全) // 产品(待补全)
product: {}, product: {},
// 需求(待补全) // 产品需求
requirement: {}, productRequirement: {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 项目需求
projectRequirement: {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 工单(待补全) // 工单(待补全)
workOrder: {} workOrder: {},
// 工作报告
workReport: {
draft: 'info',
pending_approval: 'warning',
approved: 'success',
rejected: 'danger'
},
// 个人事项
personalItem: {
pending: 'info',
active: 'primary',
completed: 'success',
cancelled: 'danger'
},
// 加班申请
overtimeApplication: {
pending: 'warning',
approved: 'success',
rejected: 'danger'
}
}; };
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType { export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
@@ -55,5 +108,9 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
return 'info'; return 'info';
} }
return statusTagTypeRegistry[domain][statusCode] || 'info'; return statusTagTypeRegistry[domain]?.[statusCode] || 'info';
}
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('personalItem', statusCode);
} }

View File

@@ -5,5 +5,6 @@ export enum SetupStoreId {
Dict = 'dict-store', Dict = 'dict-store',
Route = 'route-store', Route = 'route-store',
Tab = 'tab-store', Tab = 'tab-store',
ObjectContext = 'object-context-store' ObjectContext = 'object-context-store',
Workbench = 'workbench-store'
} }

View File

@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function * @param callback callback function
*/ */
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) { async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory); const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts); Object.assign(chartOptions, updatedOpts);
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options待 render() 初始化时一并应用;
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
if (!isRendered()) return;
if (isRendered()) { if (isRendered()) {
chart?.clear(); chart?.clear();
} }

View File

@@ -1,158 +0,0 @@
import { computed, effectScope, onScopeDispose, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import VChart, { registerLiquidChart } from '@visactor/vchart';
import type { ISpec, ITheme } from '@visactor/vchart';
import light from '@visactor/vchart-theme/public/light.json';
import dark from '@visactor/vchart-theme/public/dark.json';
import { useThemeStore } from '@/store/modules/theme';
registerLiquidChart();
// register the theme
VChart.ThemeManager.registerTheme('light', light as ITheme);
VChart.ThemeManager.registerTheme('dark', dark as ITheme);
interface ChartHooks {
onRender?: (chart: VChart) => void | Promise<void>;
onUpdated?: (chart: VChart) => void | Promise<void>;
onDestroy?: (chart: VChart) => void | Promise<void>;
}
export function useVChart<T extends ISpec>(specFactory: () => T, hooks: ChartHooks = {}) {
const scope = effectScope();
const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode);
const domRef = ref<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: VChart | null = null;
const spec: T = specFactory();
const { onRender, onUpdated, onDestroy } = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart);
}
/**
* update chart spec
*
* @param callback callback function
*/
async function updateSpec(callback: (opts: T, optsFactory: () => T) => ISpec = () => spec) {
if (!isRendered()) return;
const updatedOpts = callback(spec, specFactory);
Object.assign(spec, updatedOpts);
// if (isRendered()) {
// chart?.release();
// }
chart?.updateSpec({ ...updatedOpts }, true);
await onUpdated?.(chart!);
}
function setSpec(newSpec: T) {
chart?.updateSpec(newSpec);
}
/** render chart */
async function render() {
if (!isRendered()) {
// apply the theme
if (darkMode.value) {
VChart.ThemeManager.setCurrentTheme('dark');
} else {
VChart.ThemeManager.setCurrentTheme('light');
}
chart = new VChart(spec, { dom: domRef.value as HTMLElement });
chart.renderSync();
await onRender?.(chart);
}
}
/** resize chart */
function resize() {
// chart?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart) return;
await onDestroy?.(chart);
chart?.release();
chart = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
await render();
await onUpdated?.(chart!);
}
/**
* render chart by size
*
* @param w width
* @param h height
*/
async function renderChartBySize(w: number, h: number) {
initialSize.width = w;
initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
}
// render chart
await render();
}
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
});
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef,
updateSpec,
setSpec
};
}

View File

@@ -0,0 +1,550 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 30 * 1000;
type TabKey = 'unread' | 'read';
interface MessageListState {
items: Api.NotifyMessage.NotifyMessage[];
pageNo: number;
total: number;
loading: boolean;
/** 是否已按当前关键字拉过第一页tab 懒加载 / 失效重拉用) */
loaded: boolean;
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
token: number;
}
function createListState(): MessageListState {
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
}
const listStates = reactive<Record<TabKey, MessageListState>>({
unread: createListState(),
read: createListState()
});
const unreadCount = ref(0);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false);
const activeTab = ref<TabKey>('unread');
const searchKeyword = ref('');
function keywordParam() {
return searchKeyword.value.trim() || undefined;
}
async function refreshUnreadCount() {
const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
unreadCount.value = data;
}
}
function resetList(tab: TabKey) {
const state = listStates[tab];
state.token += 1;
state.items = [];
state.pageNo = 1;
state.total = 0;
state.loading = false;
state.loaded = false;
}
async function loadPage(tab: TabKey) {
const state = listStates[tab];
if (state.loading) return;
const token = state.token;
state.loading = true;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
});
if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => {
applyKeywordSearch();
});
watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null);
const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef,
() => {
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
loadPage('unread');
}
},
{ distance: 48 }
);
useInfiniteScroll(
() => readScrollbar.value?.wrapRef,
() => {
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
loadPage('read');
}
},
{ distance: 48 }
);
function openDrawer() {
drawerOpen.value = true;
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
resetList('unread');
resetList('read');
loadPage(activeTab.value);
refreshUnreadCount();
}
function closeDrawer() {
drawerOpen.value = false;
}
function onDrawerClosed() {
searchKeyword.value = '';
}
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
if (error) return;
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
const state = listStates.unread;
const index = state.items.findIndex(row => row.id === item.id);
if (index >= 0) {
state.items.splice(index, 1);
state.total = Math.max(0, state.total - 1);
}
unreadCount.value = Math.max(0, unreadCount.value - 1);
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
resetList('read');
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
loadPage('unread');
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
refreshUnreadCount();
}, UNREAD_COUNT_POLL_INTERVAL);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template>
<button
class="notification-bell__trigger"
type="button"
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
@click="openDrawer"
>
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button>
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
<template #header>
<div class="notification-bell__header-main">
<span class="notification-bell__title">
通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span>
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
</div>
</template>
<div class="notification-bell__panel">
<div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix>
<SvgIcon icon="mdi:magnify" />
</template>
</ElInput>
</div>
<ElTabs v-model="activeTab" class="notification-bell__tabs">
<ElTabPane name="unread">
<template #label>
<span class="notification-bell__tab-label">
未读
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
<li
v-for="row in listStates.unread.items"
:key="row.id"
class="notification-bell__row is-unread"
@click="markRead(row)"
>
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div>
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
<ElTabPane name="read">
<template #label>
<span class="notification-bell__tab-label">
已读
<span class="notification-bell__tab-count">{{ listStates.read.total }}</span>
</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li v-for="row in listStates.read.items" :key="row.id" class="notification-bell__row">
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div>
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer>
</template>
<style scoped>
.notification-bell__trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
margin: 0 4px;
border: none;
border-radius: 8px;
background-color: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
}
.notification-bell__trigger:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.notification-bell__trigger:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.notification-bell__icon {
font-size: 20px;
}
.notification-bell__badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid #fff;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 16px;
text-align: center;
animation: notification-badge-pulse 1.6s ease-in-out infinite;
}
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
.notification-bell__badge::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 999px;
background-color: var(--el-color-danger);
animation: notification-badge-ping 1.6s ease-out infinite;
z-index: -1;
}
@keyframes notification-badge-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
}
@keyframes notification-badge-ping {
0% {
transform: scale(1);
opacity: 0.6;
}
70%,
100% {
transform: scale(1.9);
opacity: 0;
}
}
.notification-bell__panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-bell__header-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
margin-right: 8px;
}
.notification-bell__title {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.notification-bell__title-count {
padding: 1px 8px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.notification-bell__search {
padding: 0 0 4px;
}
.notification-bell__tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.notification-bell__tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notification-bell__tabs :deep(.el-tab-pane) {
height: 100%;
}
.notification-bell__tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.notification-bell__tab-count {
padding: 0 7px;
border-radius: 999px;
background-color: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.notification-bell__scroll {
height: 100%;
}
.notification-bell__list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-bell__row {
position: relative;
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
padding: 12px 4px;
border-radius: 8px;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row.is-unread {
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row.is-unread:hover {
background-color: var(--el-fill-color-light);
}
.notification-bell__row-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background-color: transparent;
justify-self: center;
}
.notification-bell__row.is-unread .notification-bell__row-dot {
background-color: var(--el-color-primary);
}
.notification-bell__row-body {
min-width: 0;
}
.notification-bell__row-title {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.5;
}
.notification-bell__row.is-unread .notification-bell__row-title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.notification-bell__row-time {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.notification-bell__empty {
padding: 48px 16px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.notification-bell__footer-hint {
padding: 12px 0 4px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
user-select: none;
}
</style>

View File

@@ -12,11 +12,13 @@ const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush(); const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIcon(); const { SvgIconVNode } = useSvgIcon();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
function loginOrRegister() { function loginOrRegister() {
toLogin(); toLogin();
} }
type DropdownKey = 'user-center' | 'logout'; type DropdownKey = 'personal-center_my-profile' | 'logout';
type DropdownOption = { type DropdownOption = {
key: DropdownKey; key: DropdownKey;
@@ -27,8 +29,8 @@ type DropdownOption = {
const options = computed(() => { const options = computed(() => {
const opts: DropdownOption[] = [ const opts: DropdownOption[] = [
{ {
label: $t('common.userCenter'), label: $t('common.myProfile'),
key: 'user-center', key: 'personal-center_my-profile',
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 }) icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
}, },
{ {
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
</template> </template>
<div class="flex items-center"> <div class="flex items-center">
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" /> <SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span> <span class="text-16px font-medium">{{ displayName }}</span>
</div> </div>
</ElDropdown> </ElDropdown>
</template> </template>

View File

@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue'; import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue'; import GlobalSearch from '../global-search/index.vue';
import ThemeButton from './components/theme-button.vue'; import ThemeButton from './components/theme-button.vue';
import NotificationBell from './components/notification-bell.vue';
import UserAvatar from './components/user-avatar.vue'; import UserAvatar from './components/user-avatar.vue';
defineOptions({ name: 'GlobalHeader' }); defineOptions({ name: 'GlobalHeader' });
@@ -40,14 +41,10 @@ const { isFullscreen, toggle } = useFullscreen();
<div> <div>
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" /> <FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
</div> </div>
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<div> <div>
<ThemeButton /> <ThemeButton />
</div> </div>
<NotificationBell />
<UserAvatar /> <UserAvatar />
</div> </div>
</DarkModeContainer> </DarkModeContainer>

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Search } from '@element-plus/icons-vue';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
defineOptions({ name: 'ObjectContextSwitcher' });
interface Props {
domainConfig: App.ObjectContext.DomainConfig;
}
type ObjectOption = {
id: string;
name: string;
code?: string | null;
createTime?: string | null;
};
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
const objectContextStore = useObjectContextStore();
const visible = ref(false);
const keyword = ref('');
const expanded = ref(false);
const loading = ref(false);
const switchingId = ref('');
const options = ref<ObjectOption[]>([]);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const OBJECT_SWITCHER_PAGE_SIZE = 100;
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
const allLabel = computed(() => `全部${domainLabel.value}`);
const placeholder = computed(() => `搜索${domainLabel.value}`);
const previewOptions = computed(() => options.value.slice(0, 3));
const displayOptions = computed(() => {
if (keyword.value.trim() || expanded.value) {
return options.value;
}
return previewOptions.value;
});
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
function sortByCreateTimeDesc(list: ObjectOption[]) {
return list.slice().sort((left, right) => {
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
return rightTime - leftTime;
});
}
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
const result =
props.domainConfig.domainKey === 'product'
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
if (result.error || !result.data) {
return {
total: 0,
list: []
};
}
const list = result.data.list.map(item => {
if (props.domainConfig.domainKey === 'product') {
const product = item as Api.Product.Product;
return {
id: product.id,
name: product.name,
code: product.code,
createTime: product.createTime
};
}
const project = item as Api.Project.Project;
return {
id: project.id,
name: project.projectName,
code: project.projectCode,
createTime: project.createTime
};
});
return {
total: result.data.total,
list
};
}
async function loadOptions() {
loading.value = true;
const keywordValue = keyword.value.trim() || undefined;
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
const restPages =
pageCount > 1
? await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
)
: [];
const list = [firstPage, ...restPages].flatMap(page => page.list);
loading.value = false;
options.value = sortByCreateTimeDesc(list);
}
function handleVisibleChange(value: boolean) {
visible.value = value;
if (value) {
expanded.value = false;
loadOptions();
}
}
async function handleSelect(option: ObjectOption) {
if (option.id === objectContextStore.objectId) {
visible.value = false;
return;
}
switchingId.value = option.id;
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
switchingId.value = '';
if (result.error) {
return;
}
visible.value = false;
const query = {
...route.query,
[OBJECT_CONTEXT_QUERY_KEY]: option.id
};
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
await router.push(targetLocation);
}
watch(
() => keyword.value,
() => {
if (!visible.value) {
return;
}
expanded.value = Boolean(keyword.value.trim());
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
loadOptions();
}, 250);
}
);
</script>
<template>
<ElPopover
:visible="visible"
trigger="click"
placement="bottom-start"
:width="300"
popper-class="object-context-switcher__popper"
@update:visible="handleVisibleChange"
>
<template #reference>
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
<icon-ep:sort class="object-context-switcher__trigger-icon" />
</button>
</template>
<div class="object-context-switcher__panel">
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
<template #suffix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
<div v-loading="loading" class="object-context-switcher__list">
<button
v-for="item in displayOptions"
:key="item.id"
type="button"
class="object-context-switcher__item"
:class="{ 'is-active': item.id === objectContextStore.objectId }"
:disabled="switchingId === item.id"
@click="handleSelect(item)"
>
<span class="object-context-switcher__item-icon">
<icon-ep:box v-if="isProductDomain" />
<icon-ep:folder v-else />
</span>
<span class="object-context-switcher__item-main">
<span class="object-context-switcher__item-name">{{ item.name }}</span>
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
</span>
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
</button>
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
</div>
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
<span>{{ allLabel }}</span>
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
</button>
</div>
</ElPopover>
</template>
<style scoped>
.object-context-switcher__trigger {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
max-width: 16rem;
height: 32px;
gap: 6px;
padding: 0 10px 0 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--el-color-primary);
cursor: pointer;
font: inherit;
line-height: 1;
}
.object-context-switcher__trigger:hover,
.object-context-switcher__trigger.is-open {
background: var(--el-color-primary-light-9);
}
.object-context-switcher__trigger-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.object-context-switcher__trigger-icon {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 13px;
}
.object-context-switcher__panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.object-context-switcher__search {
padding: 4px 4px 0;
}
.object-context-switcher__list {
min-height: 84px;
max-height: 300px;
overflow-y: auto;
}
.object-context-switcher__item {
appearance: none;
-webkit-appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 42px;
gap: 10px;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--el-text-color-primary);
cursor: pointer;
font: inherit;
text-align: left;
}
.object-context-switcher__item:hover,
.object-context-switcher__item.is-active {
background: rgb(59 130 246 / 10%);
}
.object-context-switcher__item:disabled {
cursor: wait;
opacity: 0.75;
}
.object-context-switcher__item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 5px;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
.object-context-switcher__item-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.object-context-switcher__item-name,
.object-context-switcher__item-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.object-context-switcher__item-name {
font-size: 13px;
}
.object-context-switcher__item-code {
color: var(--el-text-color-placeholder);
font-size: 11px;
}
.object-context-switcher__check {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 14px;
}
.object-context-switcher__all {
appearance: none;
-webkit-appearance: none;
display: flex;
align-items: center;
width: calc(100% + 24px);
height: 38px;
gap: 8px;
margin: 0 -12px -12px;
padding: 0 14px;
border: none;
border-top: 1px solid var(--el-border-color-lighter);
background: transparent;
color: var(--el-text-color-primary);
cursor: pointer;
font: inherit;
text-align: left;
}
.object-context-switcher__all:hover {
background: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.object-context-switcher__all-meta {
flex: 1;
color: var(--el-text-color-placeholder);
font-size: 12px;
text-align: right;
}
.object-context-switcher__all-arrow {
color: var(--el-text-color-placeholder);
font-size: 13px;
}
:global(.object-context-switcher__popper.el-popover) {
padding: 12px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 10px;
box-shadow:
0 12px 28px rgb(15 23 42 / 10%),
0 2px 8px rgb(15 23 42 / 6%);
}
</style>

View File

@@ -8,6 +8,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue'; import FirstLevelMenu from '../components/first-level-menu.vue';
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
import { useMenu, useMixMenuContext } from '../../../context'; import { useMenu, useMixMenuContext } from '../../../context';
defineOptions({ defineOptions({
@@ -108,7 +109,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]" class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
></div> ></div>
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center"> <div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span> <ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
</div> </div>
<div <div
v-if="showObjectContextInfo && headerMenus.length" v-if="showObjectContextInfo && headerMenus.length"
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
white-space: nowrap; white-space: nowrap;
} }
.context-object-tag {
flex-shrink: 0;
min-width: 0;
}
.context-object-tag__label {
display: inline-flex;
align-items: center;
max-width: 14rem;
height: 32px;
padding: 0 12px;
border: 1px solid rgb(148 163 184 / 26%);
border-radius: 999px;
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
color: rgb(15 23 42 / 88%);
font-size: 13px;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-nav-list { .header-nav-list {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue'; import SettingItem from '../components/setting-item.vue';
@@ -9,16 +8,6 @@ defineOptions({ name: 'DarkMode' });
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const icons: Record<UnionKey.ThemeScheme, string> = {
light: 'material-symbols:sunny',
dark: 'material-symbols:nightlight-rounded',
auto: 'material-symbols:hdr-auto'
};
function handleSegmentChange(value: string | number) {
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
}
function handleGrayscaleChange(value: boolean) { function handleGrayscaleChange(value: boolean) {
themeStore.setGrayscale(value); themeStore.setGrayscale(value);
} }
@@ -33,15 +22,6 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
<template> <template>
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider> <ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
<div class="flex-col-stretch gap-16px"> <div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
<template #label>
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
</template>
</ElTabPane>
</ElTabs>
</div>
<Transition name="sider-inverted"> <Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')"> <SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<ElSwitch v-model="themeStore.sider.inverted" /> <ElSwitch v-model="themeStore.sider.inverted" />

View File

@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: 'Trigger', trigger: 'Trigger',
update: 'Update', update: 'Update',
updateSuccess: 'Update Success', updateSuccess: 'Update Success',
userCenter: 'User Center', myProfile: 'My Profile',
yesOrNo: { yesOrNo: {
yes: 'Yes', yes: 'Yes',
no: 'No' no: 'No'
@@ -158,17 +158,28 @@ const local: App.I18n.Schema = {
404: 'Page Not Found', 404: 'Page Not Found',
500: 'Server Error', 500: 'Server Error',
'iframe-page': 'Iframe', 'iframe-page': 'Iframe',
'user-center': 'User Center', workbench: 'Workbench',
function: 'System Function', ticket: 'Ticket',
function_tab: 'Tab', 'ticket_my-submitted': 'My Submitted',
'function_multi-tab': 'Multi Tab', 'ticket_my-pending': 'My Pending',
'function_hide-child': 'Hide Child', metrics: 'Metrics',
'function_hide-child_one': 'Hide Child', 'metrics_project-progress': 'Project Progress',
'function_hide-child_two': 'Two', 'metrics_member-efficiency': 'Member Efficiency',
'function_hide-child_three': 'Three', metrics_worktime: 'Worktime',
function_request: 'Request', 'personal-center': 'Personal Center',
'function_toggle-auth': 'Toggle Auth', 'personal-center_my-profile': 'My Profile',
'function_super-page': 'Super Admin Visible', 'personal-center_my-item': 'My Items',
'personal-center_work-report': 'Work Report',
'personal-center_work-report_weekly': 'Weekly Report',
'personal-center_work-report_monthly': 'Monthly Report',
'personal-center_work-report_project': 'Project Fortnightly Report',
'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application',
'personal-center_overtime-application': 'Overtime Application',
'personal-center_pending-approval': 'Pending Approval',
infra: 'Infra',
'infra_state-machine': 'State Machine',
'infra_rd-code': 'R&D Code',
product: 'Product', product: 'Product',
product_list: 'Product List', product_list: 'Product List',
product_dashboard: 'Dashboard', product_dashboard: 'Dashboard',
@@ -192,31 +203,7 @@ const local: App.I18n.Schema = {
exception: 'Exception', exception: 'Exception',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: 'Plugin',
plugin_copy: 'Copy',
plugin_charts: 'Charts',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_editor: 'Editor',
plugin_editor_quill: 'Quill',
plugin_editor_markdown: 'Markdown',
plugin_icon: 'Icon',
plugin_map: 'Map',
plugin_print: 'Print',
plugin_swiper: 'Swiper',
plugin_video: 'Video',
plugin_barcode: 'Barcode',
plugin_pinyin: 'pinyin',
plugin_excel: 'Excel',
plugin_pdf: 'PDF preview',
plugin_gantt: 'Gantt Chart',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: 'Typeit',
plugin_tables: 'Tables',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -312,45 +299,6 @@ const local: App.I18n.Schema = {
}, },
creativity: 'Creativity' creativity: 'Creativity'
}, },
function: {
tab: {
tabOperate: {
title: 'Tab Operation',
addTab: 'Add Tab',
addTabDesc: 'To user management page',
closeTab: 'Close Tab',
closeCurrentTab: 'Close Current Tab',
closeAboutTab: 'Close "User Management" Tab',
addMultiTab: 'Add Multi Tab',
addMultiTabDesc1: 'To MultiTab page',
addMultiTabDesc2: 'To MultiTab page(with query params)'
},
tabTitle: {
title: 'Tab Title',
changeTitle: 'Change Title',
change: 'Change',
resetTitle: 'Reset Title',
reset: 'Reset'
}
},
multiTab: {
routeParam: 'Route Param',
backTab: 'Back function_tab'
},
toggleAuth: {
toggleAccount: 'Toggle Account',
authHook: 'Auth Hook Function `hasAuth`',
superAdminVisible: 'Super Admin Visible',
adminVisible: 'Admin Visible',
adminOrUserVisible: 'Admin and User Visible'
},
request: {
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
repeatedError: 'Repeated Request Error',
repeatedErrorMsg1: 'Custom Request Error 1',
repeatedErrorMsg2: 'Custom Request Error 2'
}
},
system: { system: {
common: { common: {
status: { status: {
@@ -495,6 +443,7 @@ const local: App.I18n.Schema = {
orgType: { orgType: {
company: 'Company', company: 'Company',
dept: 'Department', dept: 'Department',
function: 'Functional Department',
direction: 'Direction', direction: 'Direction',
team: 'Team' team: 'Team'
}, },
@@ -692,6 +641,7 @@ const local: App.I18n.Schema = {
dictStatus: 'Dictionary Status', dictStatus: 'Dictionary Status',
dictLabel: 'Dictionary Label', dictLabel: 'Dictionary Label',
dictValue: 'Dictionary Value', dictValue: 'Dictionary Value',
colorType: 'Color Type',
sort: 'Sort', sort: 'Sort',
remark: 'Remark', remark: 'Remark',
form: { form: {
@@ -700,6 +650,7 @@ const local: App.I18n.Schema = {
dictStatus: 'Please select dictionary status', dictStatus: 'Please select dictionary status',
dictLabel: 'Please enter dictionary label', dictLabel: 'Please enter dictionary label',
dictValue: 'Please enter dictionary value', dictValue: 'Please enter dictionary value',
colorType: 'Please enter color type',
sort: 'Please enter sort', sort: 'Please enter sort',
remark: 'Please enter remark' remark: 'Please enter remark'
}, },

View File

@@ -1,6 +1,6 @@
const local: App.I18n.Schema = { const local: App.I18n.Schema = {
system: { system: {
title: '研发内部管理系统' title: '研发管理系统'
}, },
common: { common: {
action: '操作', action: '操作',
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: '触发', trigger: '触发',
update: '更新', update: '更新',
updateSuccess: '更新成功', updateSuccess: '更新成功',
userCenter: '个人中心', myProfile: '个人信息',
yesOrNo: { yesOrNo: {
yes: '是', yes: '是',
no: '否' no: '否'
@@ -158,17 +158,28 @@ const local: App.I18n.Schema = {
404: '页面不存在', 404: '页面不存在',
500: '服务器错误', 500: '服务器错误',
'iframe-page': '外链页面', 'iframe-page': '外链页面',
'user-center': '个人中心', workbench: '工作台',
function: '系统功能', ticket: '工单',
function_tab: '标签页', 'ticket_my-submitted': '我提交的工单',
'function_multi-tab': '多标签页', 'ticket_my-pending': '待我处理的工单',
'function_hide-child': '隐藏子菜单', metrics: '效能度量',
'function_hide-child_one': '隐藏子菜单', 'metrics_project-progress': '项目进度',
'function_hide-child_two': '菜单二', 'metrics_member-efficiency': '员工能效',
'function_hide-child_three': '菜单三', metrics_worktime: '工时统计',
function_request: '请求', 'personal-center': '个人中心',
'function_toggle-auth': '切换权限', 'personal-center_my-profile': '个人信息',
'function_super-page': '超级管理员可见', 'personal-center_my-item': '我的事项',
'personal-center_work-report': '工作报告',
'personal-center_work-report_weekly': '个人周报',
'personal-center_work-report_monthly': '个人月报',
'personal-center_work-report_project': '项目半月报',
'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请',
'personal-center_overtime-application': '加班申请',
'personal-center_pending-approval': '待我审批',
infra: '基础设施',
'infra_state-machine': '状态机管理',
'infra_rd-code': '研发令号',
product: '产品管理', product: '产品管理',
product_list: '产品列表', product_list: '产品列表',
product_dashboard: '产品仪表盘', product_dashboard: '产品仪表盘',
@@ -192,31 +203,7 @@ const local: App.I18n.Schema = {
exception: '异常页', exception: '异常页',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: '插件示例',
plugin_copy: '剪贴板',
plugin_charts: '图表',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_editor: '编辑器',
plugin_editor_quill: '富文本编辑器',
plugin_editor_markdown: 'MD 编辑器',
plugin_icon: '图标',
plugin_map: '地图',
plugin_print: '打印',
plugin_swiper: 'Swiper',
plugin_video: '视频',
plugin_barcode: '条形码',
plugin_pinyin: '拼音',
plugin_excel: 'Excel',
plugin_pdf: 'PDF 预览',
plugin_gantt: '甘特图',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: '打字机',
plugin_tables: '表格',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -268,7 +255,7 @@ const local: App.I18n.Schema = {
about: { about: {
title: '关于', title: '关于',
introduction: introduction:
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。', '灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
projectInfo: { projectInfo: {
title: '项目信息', title: '项目信息',
version: '版本', version: '版本',
@@ -311,45 +298,6 @@ const local: App.I18n.Schema = {
}, },
creativity: '创意' creativity: '创意'
}, },
function: {
tab: {
tabOperate: {
title: '标签页操作',
addTab: '添加标签页',
addTabDesc: '跳转到用户管理页面',
closeTab: '关闭标签页',
closeCurrentTab: '关闭当前标签页',
closeAboutTab: '关闭"用户管理"标签页',
addMultiTab: '添加多标签页',
addMultiTabDesc1: '跳转到多标签页页面',
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
},
tabTitle: {
title: '标签页标题',
changeTitle: '修改标题',
change: '修改',
resetTitle: '重置标题',
reset: '重置'
}
},
multiTab: {
routeParam: '路由参数',
backTab: '返回 function_tab'
},
toggleAuth: {
toggleAccount: '切换账号',
authHook: '权限钩子函数 `hasAuth`',
superAdminVisible: '超级管理员可见',
adminVisible: '管理员可见',
adminOrUserVisible: '管理员和用户可见'
},
request: {
repeatedErrorOccurOnce: '重复请求错误只出现一次',
repeatedError: '重复请求错误',
repeatedErrorMsg1: '自定义请求错误 1',
repeatedErrorMsg2: '自定义请求错误 2'
}
},
system: { system: {
common: { common: {
status: { status: {
@@ -491,6 +439,7 @@ const local: App.I18n.Schema = {
orgType: { orgType: {
company: '公司', company: '公司',
dept: '部门', dept: '部门',
function: '职能部门',
direction: '方向', direction: '方向',
team: '团队' team: '团队'
}, },
@@ -680,6 +629,7 @@ const local: App.I18n.Schema = {
dictStatus: '字典状态', dictStatus: '字典状态',
dictLabel: '字典标签', dictLabel: '字典标签',
dictValue: '字典键值', dictValue: '字典键值',
colorType: '颜色类型',
sort: '排序', sort: '排序',
remark: '备注', remark: '备注',
form: { form: {
@@ -688,6 +638,7 @@ const local: App.I18n.Schema = {
dictStatus: '请选择字典状态', dictStatus: '请选择字典状态',
dictLabel: '请输入字典标签', dictLabel: '请输入字典标签',
dictValue: '请输入字典键值', dictValue: '请输入字典键值',
colorType: '请输入颜色类型',
sort: '请输入排序', sort: '请输入排序',
remark: '请输入备注' remark: '请输入备注'
}, },

View File

@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
import 'element-plus/theme-chalk/dark/css-vars.css'; import 'element-plus/theme-chalk/dark/css-vars.css';
import 'uno.css'; import 'uno.css';
import '../styles/css/global.css'; import '../styles/css/global.css';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';

View File

@@ -1,9 +1,11 @@
import { extend } from 'dayjs'; import { extend } from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
import { setDayjsLocale } from '../locales/dayjs'; import { setDayjsLocale } from '../locales/dayjs';
export function setupDayjs() { export function setupDayjs() {
extend(localeData); extend(localeData);
extend(isoWeek);
setDayjsLocale(); setDayjsLocale();
} }

View File

@@ -20,33 +20,21 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"), 500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"), "iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"), login: () => import("@/views/_builtin/login/index.vue"),
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"), "infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"), "infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"), "metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"), "metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
function_request: () => import("@/views/function/request/index.vue"), metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"function_super-page": () => import("@/views/function/super-page/index.vue"), "personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"), "personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"), "personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"), "personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"), "personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"), "personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"), "personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
plugin_copy: () => import("@/views/plugin/copy/index.vue"), "personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"), "personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
plugin_editor_quill: () => import("@/views/plugin/editor/quill/index.vue"), "personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
plugin_icon: () => import("@/views/plugin/icon/index.vue"),
plugin_map: () => import("@/views/plugin/map/index.vue"),
plugin_pdf: () => import("@/views/plugin/pdf/index.vue"),
plugin_pinyin: () => import("@/views/plugin/pinyin/index.vue"),
plugin_print: () => import("@/views/plugin/print/index.vue"),
plugin_swiper: () => import("@/views/plugin/swiper/index.vue"),
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
plugin_video: () => import("@/views/plugin/video/index.vue"),
product_dashboard: () => import("@/views/product/dashboard/index.vue"), product_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"), product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"), product_requirement: () => import("@/views/product/requirement/index.vue"),
@@ -63,5 +51,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"), "system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"), "system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
system_user: () => import("@/views/system/user/index.vue"), system_user: () => import("@/views/system/user/index.vue"),
"user-center": () => import("@/views/user-center/index.vue"), "ticket_my-pending": () => import("@/views/ticket/my-pending/index.vue"),
"ticket_my-submitted": () => import("@/views/ticket/my-submitted/index.vue"),
workbench: () => import("@/views/workbench/index.vue"),
}; };

View File

@@ -39,124 +39,6 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true hideInMenu: true
} }
}, },
{
name: 'function',
path: '/function',
component: 'layout.base',
meta: {
title: 'function',
i18nKey: 'route.function',
icon: 'icon-park-outline:all-application',
order: 6
},
children: [
{
name: 'function_hide-child',
path: '/function/hide-child',
meta: {
title: 'function_hide-child',
i18nKey: 'route.function_hide-child',
icon: 'material-symbols:filter-list-off',
order: 2
},
redirect: '/function/hide-child/one',
children: [
{
name: 'function_hide-child_one',
path: '/function/hide-child/one',
component: 'view.function_hide-child_one',
meta: {
title: 'function_hide-child_one',
i18nKey: 'route.function_hide-child_one',
icon: 'material-symbols:filter-list-off',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_three',
path: '/function/hide-child/three',
component: 'view.function_hide-child_three',
meta: {
title: 'function_hide-child_three',
i18nKey: 'route.function_hide-child_three',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_two',
path: '/function/hide-child/two',
component: 'view.function_hide-child_two',
meta: {
title: 'function_hide-child_two',
i18nKey: 'route.function_hide-child_two',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
}
]
},
{
name: 'function_multi-tab',
path: '/function/multi-tab',
component: 'view.function_multi-tab',
meta: {
title: 'function_multi-tab',
i18nKey: 'route.function_multi-tab',
icon: 'ic:round-tab',
multiTab: true,
hideInMenu: true,
activeMenu: 'function_tab'
}
},
{
name: 'function_request',
path: '/function/request',
component: 'view.function_request',
meta: {
title: 'function_request',
i18nKey: 'route.function_request',
icon: 'carbon:network-overlay',
order: 3
}
},
{
name: 'function_super-page',
path: '/function/super-page',
component: 'view.function_super-page',
meta: {
title: 'function_super-page',
i18nKey: 'route.function_super-page',
icon: 'ic:round-supervisor-account',
order: 5,
roles: ['R_SUPER']
}
},
{
name: 'function_tab',
path: '/function/tab',
component: 'view.function_tab',
meta: {
title: 'function_tab',
i18nKey: 'route.function_tab',
icon: 'ic:round-tab',
order: 1
}
},
{
name: 'function_toggle-auth',
path: '/function/toggle-auth',
component: 'view.function_toggle-auth',
meta: {
title: 'function_toggle-auth',
i18nKey: 'route.function_toggle-auth',
icon: 'ic:round-construction',
order: 4
}
}
]
},
{ {
name: 'iframe-page', name: 'iframe-page',
path: '/iframe-page/:url', path: '/iframe-page/:url',
@@ -170,6 +52,43 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'infra',
path: '/infra',
component: 'layout.base',
meta: {
title: 'infra',
i18nKey: 'route.infra',
icon: 'ep:monitor',
order: 20
},
children: [
{
name: 'infra_rd-code',
path: '/infra/rd-code',
component: 'view.infra_rd-code',
meta: {
title: 'infra_rd-code',
i18nKey: 'route.infra_rd-code',
icon: 'mdi:identifier',
order: 2,
keepAlive: true
}
},
{
name: 'infra_state-machine',
path: '/infra/state-machine',
component: 'view.infra_state-machine',
meta: {
title: 'infra_state-machine',
i18nKey: 'route.infra_state-machine',
icon: 'mdi:state-machine',
order: 1,
keepAlive: true
}
}
]
},
{ {
name: 'login', name: 'login',
path: '/login/:module(pwd-login|reset-pwd)?', path: '/login/:module(pwd-login|reset-pwd)?',
@@ -183,250 +102,183 @@ export const generatedRoutes: GeneratedRoute[] = [
} }
}, },
{ {
name: 'plugin', name: 'metrics',
path: '/plugin', path: '/metrics',
component: 'layout.base', component: 'layout.base',
meta: { meta: {
title: '插件示例', title: 'metrics',
i18nKey: 'route.plugin', i18nKey: 'route.metrics',
order: 7, icon: 'mdi:chart-line',
icon: 'clarity:plugin-line' order: 7
}, },
children: [ children: [
{ {
name: 'plugin_barcode', name: 'metrics_member-efficiency',
path: '/plugin/barcode', path: '/metrics/member-efficiency',
component: 'view.plugin_barcode', component: 'view.metrics_member-efficiency',
meta: { meta: {
title: 'plugin_barcode', title: 'metrics_member-efficiency',
i18nKey: 'route.plugin_barcode', i18nKey: 'route.metrics_member-efficiency',
icon: 'ic:round-barcode' icon: 'mdi:account-multiple-check-outline',
} order: 2,
},
{
name: 'plugin_charts',
path: '/plugin/charts',
meta: {
title: 'plugin_charts',
i18nKey: 'route.plugin_charts',
icon: 'mdi:chart-areaspline'
},
children: [
{
name: 'plugin_charts_antv',
path: '/plugin/charts/antv',
component: 'view.plugin_charts_antv',
meta: {
title: 'plugin_charts_antv',
i18nKey: 'route.plugin_charts_antv',
icon: 'hugeicons:flow-square'
}
},
{
name: 'plugin_charts_echarts',
path: '/plugin/charts/echarts',
component: 'view.plugin_charts_echarts',
meta: {
title: 'plugin_charts_echarts',
i18nKey: 'route.plugin_charts_echarts',
icon: 'simple-icons:apacheecharts'
}
},
{
name: 'plugin_charts_vchart',
path: '/plugin/charts/vchart',
component: 'view.plugin_charts_vchart',
meta: {
title: 'plugin_charts_vchart',
i18nKey: 'route.plugin_charts_vchart',
localIcon: 'visactor'
}
}
]
},
{
name: 'plugin_copy',
path: '/plugin/copy',
component: 'view.plugin_copy',
meta: {
title: 'plugin_copy',
i18nKey: 'route.plugin_copy',
icon: 'mdi:clipboard-outline'
}
},
{
name: 'plugin_editor',
path: '/plugin/editor',
meta: {
title: 'plugin_editor',
i18nKey: 'route.plugin_editor',
icon: 'icon-park-outline:editor'
},
children: [
{
name: 'plugin_editor_markdown',
path: '/plugin/editor/markdown',
component: 'view.plugin_editor_markdown',
meta: {
title: 'plugin_editor_markdown',
i18nKey: 'route.plugin_editor_markdown',
icon: 'ri:markdown-line'
}
},
{
name: 'plugin_editor_quill',
path: '/plugin/editor/quill',
component: 'view.plugin_editor_quill',
meta: {
title: 'plugin_editor_quill',
i18nKey: 'route.plugin_editor_quill',
icon: 'mdi:file-document-edit-outline'
}
}
]
},
{
name: 'plugin_excel',
path: '/plugin/excel',
component: 'view.plugin_excel',
meta: {
title: 'plugin_excel',
i18nKey: 'route.plugin_excel',
icon: 'ri:file-excel-2-line',
keepAlive: true keepAlive: true
} }
}, },
{ {
name: 'plugin_gantt', name: 'metrics_project-progress',
path: '/plugin/gantt', path: '/metrics/project-progress',
component: 'view.metrics_project-progress',
meta: { meta: {
title: 'plugin_gantt', title: 'metrics_project-progress',
i18nKey: 'route.plugin_gantt', i18nKey: 'route.metrics_project-progress',
icon: 'ant-design:bar-chart-outlined' icon: 'mdi:progress-clock',
}, order: 1,
children: [ keepAlive: true
{
name: 'plugin_gantt_dhtmlx',
path: '/plugin/gantt/dhtmlx',
component: 'view.plugin_gantt_dhtmlx',
meta: {
title: 'plugin_gantt_dhtmlx',
i18nKey: 'route.plugin_gantt_dhtmlx',
icon: 'gridicons:posts'
} }
}, },
{ {
name: 'plugin_gantt_vtable', name: 'metrics_worktime',
path: '/plugin/gantt/vtable', path: '/metrics/worktime',
component: 'view.plugin_gantt_vtable', component: 'view.metrics_worktime',
meta: { meta: {
title: 'plugin_gantt_vtable', title: 'metrics_worktime',
i18nKey: 'route.plugin_gantt_vtable', i18nKey: 'route.metrics_worktime',
localIcon: 'visactor' icon: 'mdi:clock-time-five-outline',
order: 3,
keepAlive: true
} }
} }
] ]
}, },
{ {
name: 'plugin_icon', name: 'personal-center',
path: '/plugin/icon', path: '/personal-center',
component: 'view.plugin_icon', component: 'layout.base',
meta: { meta: {
title: 'plugin_icon', title: 'personal-center',
i18nKey: 'route.plugin_icon', i18nKey: 'route.personal-center',
localIcon: 'custom-icon' icon: 'mdi:account-circle-outline',
} order: 8
},
{
name: 'plugin_map',
path: '/plugin/map',
component: 'view.plugin_map',
meta: {
title: 'plugin_map',
i18nKey: 'route.plugin_map',
icon: 'mdi:map'
}
},
{
name: 'plugin_pdf',
path: '/plugin/pdf',
component: 'view.plugin_pdf',
meta: {
title: 'plugin_pdf',
i18nKey: 'route.plugin_pdf',
icon: 'uiw:file-pdf'
}
},
{
name: 'plugin_pinyin',
path: '/plugin/pinyin',
component: 'view.plugin_pinyin',
meta: {
title: 'plugin_pinyin',
i18nKey: 'route.plugin_pinyin',
icon: 'entypo-social:google-hangouts'
}
},
{
name: 'plugin_print',
path: '/plugin/print',
component: 'view.plugin_print',
meta: {
title: 'plugin_print',
i18nKey: 'route.plugin_print',
icon: 'mdi:printer'
}
},
{
name: 'plugin_swiper',
path: '/plugin/swiper',
component: 'view.plugin_swiper',
meta: {
title: 'plugin_swiper',
i18nKey: 'route.plugin_swiper',
icon: 'simple-icons:swiper'
}
},
{
name: 'plugin_tables',
path: '/plugin/tables',
meta: {
title: 'plugin_tables',
i18nKey: 'route.plugin_tables',
icon: 'icon-park-outline:table'
}, },
children: [ children: [
{ {
name: 'plugin_tables_vtable', name: 'personal-center_my-application',
path: '/plugin/tables/vtable', path: '/personal-center/my-application',
component: 'view.plugin_tables_vtable', component: 'view.personal-center_my-application',
meta: { meta: {
title: 'plugin_tables_vtable', title: 'personal-center_my-application',
i18nKey: 'route.plugin_tables_vtable', i18nKey: 'route.personal-center_my-application',
localIcon: 'visactor' icon: 'mdi:file-document-outline',
order: 4,
keepAlive: true
}
},
{
name: 'personal-center_my-item',
path: '/personal-center/my-item',
component: 'view.personal-center_my-item',
meta: {
title: 'personal-center_my-item',
i18nKey: 'route.personal-center_my-item',
icon: 'mdi:checkbox-multiple-blank-circle-outline',
order: 1,
keepAlive: true
}
},
{
name: 'personal-center_my-performance',
path: '/personal-center/my-performance',
component: 'view.personal-center_my-performance',
meta: {
title: 'personal-center_my-performance',
i18nKey: 'route.personal-center_my-performance',
icon: 'mdi:trophy-outline',
order: 3,
keepAlive: true
}
},
{
name: 'personal-center_my-profile',
path: '/personal-center/my-profile',
component: 'view.personal-center_my-profile',
meta: {
title: 'personal-center_my-profile',
i18nKey: 'route.personal-center_my-profile',
icon: 'mdi:account-box-outline',
order: 0,
keepAlive: true
}
},
{
name: 'personal-center_overtime-application',
path: '/personal-center/overtime-application',
component: 'view.personal-center_overtime-application',
meta: {
title: 'personal-center_overtime-application',
i18nKey: 'route.personal-center_overtime-application',
icon: 'mdi:clock-plus-outline',
order: 6,
keepAlive: true
}
},
{
name: 'personal-center_pending-approval',
path: '/personal-center/pending-approval',
component: 'view.personal-center_pending-approval',
meta: {
title: 'personal-center_pending-approval',
i18nKey: 'route.personal-center_pending-approval',
icon: 'mdi:check-decagram-outline',
order: 7,
keepAlive: true
}
},
{
name: 'personal-center_work-report',
path: '/personal-center/work-report',
component: 'view.personal-center_work-report',
meta: {
title: 'personal-center_work-report',
i18nKey: 'route.personal-center_work-report',
icon: 'mdi:file-chart-outline',
order: 3,
keepAlive: true
},
children: [
{
name: 'personal-center_work-report_monthly',
path: '/personal-center/work-report/monthly',
component: 'view.personal-center_work-report_monthly',
meta: {
title: 'personal-center_work-report_monthly',
i18nKey: 'route.personal-center_work-report_monthly',
hideInMenu: true,
activeMenu: 'personal-center_work-report'
}
},
{
name: 'personal-center_work-report_project',
path: '/personal-center/work-report/project',
component: 'view.personal-center_work-report_project',
meta: {
title: 'personal-center_work-report_project',
i18nKey: 'route.personal-center_work-report_project',
hideInMenu: true,
activeMenu: 'personal-center_work-report'
}
},
{
name: 'personal-center_work-report_weekly',
path: '/personal-center/work-report/weekly',
component: 'view.personal-center_work-report_weekly',
meta: {
title: 'personal-center_work-report_weekly',
i18nKey: 'route.personal-center_work-report_weekly',
hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} }
] ]
},
{
name: 'plugin_typeit',
path: '/plugin/typeit',
component: 'view.plugin_typeit',
meta: {
title: 'plugin_typeit',
i18nKey: 'route.plugin_typeit',
icon: 'mdi:typewriter'
}
},
{
name: 'plugin_video',
path: '/plugin/video',
component: 'view.plugin_video',
meta: {
title: 'plugin_video',
i18nKey: 'route.plugin_video',
icon: 'mdi:video'
}
} }
] ]
}, },
@@ -664,13 +516,53 @@ export const generatedRoutes: GeneratedRoute[] = [
] ]
}, },
{ {
name: 'user-center', name: 'ticket',
path: '/user-center', path: '/ticket',
component: 'layout.base$view.user-center', component: 'layout.base',
meta: { meta: {
title: 'user-center', title: 'ticket',
i18nKey: 'route.user-center', i18nKey: 'route.ticket',
hideInMenu: true icon: 'mdi:ticket-confirmation-outline',
order: 6
},
children: [
{
name: 'ticket_my-pending',
path: '/ticket/my-pending',
component: 'view.ticket_my-pending',
meta: {
title: 'ticket_my-pending',
i18nKey: 'route.ticket_my-pending',
icon: 'mdi:inbox-arrow-down-outline',
order: 2,
keepAlive: true
}
},
{
name: 'ticket_my-submitted',
path: '/ticket/my-submitted',
component: 'view.ticket_my-submitted',
meta: {
title: 'ticket_my-submitted',
i18nKey: 'route.ticket_my-submitted',
icon: 'mdi:upload-outline',
order: 1,
keepAlive: true
}
}
]
},
{
name: 'workbench',
path: '/workbench',
component: 'layout.base$view.workbench',
meta: {
title: 'workbench',
i18nKey: 'route.workbench',
icon: 'mdi:view-dashboard-outline',
order: 1,
keepAlive: true,
constant: true
} }
} }
]; ];

View File

@@ -170,42 +170,26 @@ const routeMap: RouteMap = {
"403": "/403", "403": "/403",
"404": "/404", "404": "/404",
"500": "/500", "500": "/500",
"function": "/function",
"function_hide-child": "/function/hide-child",
"function_hide-child_one": "/function/hide-child/one",
"function_hide-child_three": "/function/hide-child/three",
"function_hide-child_two": "/function/hide-child/two",
"function_multi-tab": "/function/multi-tab",
"function_request": "/function/request",
"function_super-page": "/function/super-page",
"function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth",
"iframe-page": "/iframe-page/:url", "iframe-page": "/iframe-page/:url",
"infra": "/infra",
"infra_rd-code": "/infra/rd-code",
"infra_state-machine": "/infra/state-machine",
"login": "/login/:module(pwd-login|reset-pwd)?", "login": "/login/:module(pwd-login|reset-pwd)?",
"plugin": "/plugin", "metrics": "/metrics",
"plugin_barcode": "/plugin/barcode", "metrics_member-efficiency": "/metrics/member-efficiency",
"plugin_charts": "/plugin/charts", "metrics_project-progress": "/metrics/project-progress",
"plugin_charts_antv": "/plugin/charts/antv", "metrics_worktime": "/metrics/worktime",
"plugin_charts_echarts": "/plugin/charts/echarts", "personal-center": "/personal-center",
"plugin_charts_vchart": "/plugin/charts/vchart", "personal-center_my-application": "/personal-center/my-application",
"plugin_copy": "/plugin/copy", "personal-center_my-item": "/personal-center/my-item",
"plugin_editor": "/plugin/editor", "personal-center_my-performance": "/personal-center/my-performance",
"plugin_editor_markdown": "/plugin/editor/markdown", "personal-center_my-profile": "/personal-center/my-profile",
"plugin_editor_quill": "/plugin/editor/quill", "personal-center_overtime-application": "/personal-center/overtime-application",
"plugin_excel": "/plugin/excel", "personal-center_pending-approval": "/personal-center/pending-approval",
"plugin_gantt": "/plugin/gantt", "personal-center_work-report": "/personal-center/work-report",
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx", "personal-center_work-report_monthly": "/personal-center/work-report/monthly",
"plugin_gantt_vtable": "/plugin/gantt/vtable", "personal-center_work-report_project": "/personal-center/work-report/project",
"plugin_icon": "/plugin/icon", "personal-center_work-report_weekly": "/personal-center/work-report/weekly",
"plugin_map": "/plugin/map",
"plugin_pdf": "/plugin/pdf",
"plugin_pinyin": "/plugin/pinyin",
"plugin_print": "/plugin/print",
"plugin_swiper": "/plugin/swiper",
"plugin_tables": "/plugin/tables",
"plugin_tables_vtable": "/plugin/tables/vtable",
"plugin_typeit": "/plugin/typeit",
"plugin_video": "/plugin/video",
"product": "/product", "product": "/product",
"product_dashboard": "/product/dashboard", "product_dashboard": "/product/dashboard",
"product_list": "/product/list", "product_list": "/product/list",
@@ -226,7 +210,10 @@ const routeMap: RouteMap = {
"system_user": "/system/user", "system_user": "/system/user",
"system_user-detail": "/system/user-detail/:id", "system_user-detail": "/system/user-detail/:id",
"system_user-management-relation": "/system/user-management-relation", "system_user-management-relation": "/system/user-management-relation",
"user-center": "/user-center" "ticket": "/ticket",
"ticket_my-pending": "/ticket/my-pending",
"ticket_my-submitted": "/ticket/my-submitted",
"workbench": "/workbench"
}; };
/** /**

View File

@@ -1,7 +1,7 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service'; import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { clearUserRouteCache } from './route'; import { clearUserRouteCache } from './route';
import type { ServiceRequestResult } from './shared'; import { type ServiceRequestResult, mapServiceResult, normalizeStringId } from './shared';
/** 后端登录返回 */ /** 后端登录返回 */
interface BackendLoginToken { interface BackendLoginToken {
@@ -14,10 +14,38 @@ interface BackendLoginToken {
interface BackendUserInfoDTO { interface BackendUserInfoDTO {
userId: string | number; userId: string | number;
userName?: string | null; userName?: string | null;
nickname?: string | null;
roles?: string[] | null; roles?: string[] | null;
buttons?: string[] | null; buttons?: string[] | null;
} }
interface BackendMyProfileDetailDTO {
id?: string | number | null;
userId?: string | number | null;
username?: string | null;
userName?: string | null;
nickname?: string | null;
company?: string | null;
email?: string | null;
mobile?: string | null;
sex?: Api.SystemManage.UserGender | null;
avatar?: string | null;
loginIp?: string | null;
loginDate?: string | null;
createTime?: string | null;
roles?: Api.SystemManage.RoleSimple[] | null;
dept?: Api.SystemManage.DeptSimple | null;
position?: Api.SystemManage.PostSimple | null;
}
interface BackendFileDTO {
id: string | number;
configId: string | number;
name?: string | null;
path: string;
url: string;
}
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null; let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
/** 将后端 token 结构转换成前端现有结构 */ /** 将后端 token 结构转换成前端现有结构 */
@@ -32,11 +60,48 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
return { return {
userId: String(data.userId ?? ''), userId: String(data.userId ?? ''),
userName: data.userName ?? '', userName: data.userName ?? '',
nickname: data.nickname ?? '',
roles: data.roles ?? [], roles: data.roles ?? [],
buttons: data.buttons ?? [] buttons: data.buttons ?? []
}; };
} }
function safeStringId(value: string | number | null | undefined): string | null {
return value === null || value === undefined ? null : String(value);
}
// eslint-disable-next-line complexity
function mapMyProfileDetail(data: BackendMyProfileDetailDTO, fallbackUserId = ''): Api.Auth.MyProfileDetail {
const baseInfo = {
userId: String(data.id ?? data.userId ?? fallbackUserId ?? ''),
username: data.username ?? data.userName ?? '',
nickname: data.nickname ?? '',
deptId: safeStringId(data.dept?.id),
deptName: data.dept?.name ?? '',
positionId: safeStringId(data.position?.id),
positionName: data.position?.name ?? ''
};
const contactInfo = {
company: data.company ?? null,
email: data.email ?? '',
mobile: data.mobile ?? '',
sex: data.sex ?? 0,
avatar: data.avatar ?? ''
};
const extraInfo = {
roles: data.roles ?? [],
dept: data.dept ?? null,
position: data.position ?? null,
loginIp: data.loginIp ?? '',
loginDate: data.loginDate ?? null,
createTime: data.createTime ?? null
};
return { ...baseInfo, ...contactInfo, ...extraInfo };
}
export function clearUserInfoCache() { export function clearUserInfoCache() {
userInfoPromise = null; userInfoPromise = null;
} }
@@ -99,19 +164,88 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
}; };
} }
/** 获取当前登录人资料详情 */
export async function fetchGetMyProfileDetail(
options: {
userId?: string;
} = {}
): Promise<ServiceRequestResult<Api.Auth.MyProfileDetail>> {
const result = await request<BackendMyProfileDetailDTO>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/get`,
method: 'get'
});
if (result.error || !result.data) {
return result as ServiceRequestResult<Api.Auth.MyProfileDetail>;
}
return {
...result,
data: mapMyProfileDetail(result.data, options.userId ?? '')
};
}
/** 更新当前登录人基础资料 */
export function fetchUpdateMyProfile(data: Api.Auth.UpdateMyProfileParams) {
return request<boolean>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update`,
method: 'put',
data
});
}
/** 修改当前登录人密码 */
export async function fetchUpdateMyAvatar(file: File) {
const formData = new FormData();
formData.append('file', file);
const result = await request<BackendFileDTO>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-avatar`,
method: 'put',
data: formData
});
return mapServiceResult(result as ServiceRequestResult<BackendFileDTO>, data => ({
...data,
id: normalizeStringId(data.id),
configId: normalizeStringId(data.configId)
}));
}
export function fetchUpdateMyPassword(data: Api.Auth.UpdateMyPasswordParams) {
return request<boolean>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-password`,
method: 'put',
data
});
}
/** /**
* 刷新 token * 刷新 token
* *
* @param refreshToken 刷新 token * @param refreshToken 刷新 token
*/ */
export function fetchRefreshToken(refreshToken: string) { export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<Api.Auth.LoginToken>> {
return request<Api.Auth.LoginToken>({ // 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded
// skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization不看 PermitAll
const result = await request<BackendLoginToken>({
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`, url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
method: 'post', method: 'post',
data: { params: { refreshToken },
refreshToken headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
} skipAuth: true,
suppressErrorMessage: true,
skipTokenRefresh: true
}); });
if (result.error || !result.data) {
return result as ServiceRequestResult<Api.Auth.LoginToken>;
}
return {
...result,
data: mapLoginToken(result.data)
};
} }
/** /**

View File

@@ -1,5 +1,6 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service'; import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult } from './shared';
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`; const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`; const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
@@ -15,6 +16,52 @@ function createBatchDeleteQuery(ids: number[]) {
return query.toString(); return query.toString();
} }
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
};
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
list: DictDataResponse[];
};
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
};
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
function normalizeColorType(value?: string | null) {
return value?.trim() || null;
}
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
};
}
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
};
}
function toSaveDictDataRequest(data: Api.Dict.SaveDictDataParams) {
return {
...data,
colorType: normalizeColorType(data.colorType),
remark: data.remark?.trim() || null
};
}
/** 获取字典类型分页 */ /** 获取字典类型分页 */
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) { export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
return request<Api.Dict.PageResult<Api.Dict.DictType>>({ return request<Api.Dict.PageResult<Api.Dict.DictType>>({
@@ -60,20 +107,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
} }
/** 获取字典数据分页 */ /** 获取字典数据分页 */
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) { export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
return request<Api.Dict.PageResult<Api.Dict.DictData>>({ const result = await request<DictDataPageResponse>({
url: `${DICT_DATA_PREFIX}/page`, url: `${DICT_DATA_PREFIX}/page`,
method: 'get', method: 'get',
params params
}); });
if (result.error || !result.data) {
return result as unknown as Awaited<ReturnType<typeof request<Api.Dict.PageResult<Api.Dict.DictData>>>>;
}
return {
...result,
data: {
...result.data,
list: result.data.list.map(normalizeDictData)
}
};
} }
/** 获取前端运行时字典缓存 */ /** 获取前端运行时字典缓存 */
export function fetchGetFrontendDictCache() { export async function fetchGetFrontendDictCache() {
return request<Api.Dict.FrontendDictCache>({ const result = await request<FrontendDictCacheResponse>({
url: `${DICT_DATA_PREFIX}/frontend-cache`, url: `${DICT_DATA_PREFIX}/frontend-cache`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<FrontendDictCacheResponse>,
data =>
Object.fromEntries(
Object.entries(data).map(([dictType, list]) => [dictType, list.map(normalizeFrontendDictData)])
) as Api.Dict.FrontendDictCache
);
} }
/** 创建字典数据 */ /** 创建字典数据 */
@@ -81,7 +148,7 @@ export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
return request<number>({ return request<number>({
url: `${DICT_DATA_PREFIX}/create`, url: `${DICT_DATA_PREFIX}/create`,
method: 'post', method: 'post',
data data: toSaveDictDataRequest(data)
}); });
} }
@@ -90,7 +157,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
return request<boolean>({ return request<boolean>({
url: `${DICT_DATA_PREFIX}/update`, url: `${DICT_DATA_PREFIX}/update`,
method: 'put', method: 'put',
data data: toSaveDictDataRequest(data)
}); });
} }
@@ -112,9 +179,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
} }
/** 通过岗位编码获取该字典的所有字典数据 */ /** 通过岗位编码获取该字典的所有字典数据 */
export function fetchGetDictDataByCode(code: string) { export async function fetchGetDictDataByCode(code: string) {
return request<Api.Dict.PageResult<Api.Dict.DictData>>({ const result = await request<DictDataPageResponse>({
url: `${DICT_DATA_PREFIX}/code?code=${code}`, url: `${DICT_DATA_PREFIX}/code?code=${code}`,
method: 'get' method: 'get'
}); });
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
...data,
list: data.list.map(normalizeDictData)
}));
} }

View File

@@ -1,19 +1,88 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service'; import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult } from './shared';
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`; const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
/**
* 拼接文件永久代理路径,用于富文本 <img src>。
*
* 后端 GET 接口匿名访问、Content-Disposition: inline私有桶下也不会过期。
* 调用方拿到上传响应里的 configId + path 后直接调用本函数得到可写入 HTML 的 url。
*/
export function buildFileProxyUrl(configId: string, path: string) {
return `${FILE_PREFIX}/${configId}/get/${encodeURI(path)}`;
}
export interface UploadFileResult {
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
id: string;
/** 对象存储配置编号(字符串形式),与 path 一起拼接永久代理路径 */
configId: string;
/** 文件相对路径(含日期目录、文件名),与 configId 一起拼接永久代理路径 */
path: string;
/**
* 文件访问 URL私有桶带签名24h 过期)、公开桶裸 URL。
* ⚠️ 仅供后端调试 / 历史兼容,禁止写进富文本 <img src> —— 会随签名过期导致回显失效。
* 富文本图片请用 buildFileProxyUrl(configId, path) 的返回值。
*/
url: string;
}
type UploadFileResponse = {
id: string | number;
configId: string | number;
path: string;
url: string;
};
/** 上传文件(模式一:后端中转) */ /** 上传文件(模式一:后端中转) */
export function uploadFile(file: File, directory?: string) { export async function uploadFile(file: File, directory?: string) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (directory) { if (directory) {
formData.append('directory', directory); formData.append('directory', directory);
} }
return request<string>({ const result = await request<UploadFileResponse>({
url: `${FILE_PREFIX}/upload`, url: `${FILE_PREFIX}/upload`,
method: 'post', method: 'post',
data: formData data: formData
}); });
return mapServiceResult(result as ServiceRequestResult<UploadFileResponse>, data => ({
id: String(data.id),
configId: String(data.configId),
path: data.path,
url: data.url
}));
}
/**
* 删除文件
*
* 业务表单"取消/关闭/标记删除"场景调用本接口清理孤儿文件。
* 删除已不存在的文件(后端返回错误码 `1001003001`)应由调用方视为成功并吞掉。
*/
export function deleteFile(id: string) {
return request<boolean>({
url: `${FILE_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
/**
* 下载文件(流)
*
* 走后端代理接口 `/system/file/download?id=xxx`,由后端读取对象存储并以字节流返回。
* 私有桶下不要直接打开 `infra_file.url`,签名地址会过期。
*/
export function downloadFile(id: string) {
return request<Blob, 'blob'>({
url: `${FILE_PREFIX}/download`,
method: 'get',
params: { id },
responseType: 'blob'
});
} }

View File

@@ -1,9 +1,16 @@
export * from './auth'; export * from './auth';
export * from './dict'; export * from './dict';
export * from './file'; export * from './file';
export * from './infra';
export * from './notice';
export * from './notify-message';
export * from './object-context'; export * from './object-context';
export * from './overtime-application';
export * from './personal-item';
export * from './product'; export * from './product';
export * from './project'; export * from './project';
export * from './project-group';
export * from './project-shared'; export * from './project-shared';
export * from './route'; export * from './route';
export * from './system-manage'; export * from './system-manage';
export * from './work-report';

208
src/service/api/infra.ts Normal file
View File

@@ -0,0 +1,208 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const OBJECT_STATUS_MODEL_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/model`;
const OBJECT_STATUS_TRANSITION_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/transition`;
type ObjectStatusModelResponse = Omit<
Api.Infra.ObjectStatusModel,
| 'id'
| 'initialFlag'
| 'terminalFlag'
| 'allowEdit'
| 'progressExcludedFlag'
| 'allowCreateProject'
| 'allowCreateRequirement'
> & {
id: string | number;
initialFlag: boolean | number | string | null | undefined;
terminalFlag: boolean | number | string | null | undefined;
allowEdit: boolean | number | string | null | undefined;
progressExcludedFlag: boolean | number | string | null | undefined;
allowCreateProject: boolean | number | string | null | undefined;
allowCreateRequirement: boolean | number | string | null | undefined;
};
type ObjectStatusTransitionResponse = Omit<Api.Infra.ObjectStatusTransition, 'id' | 'needReason'> & {
id: string | number;
needReason: boolean | number | string | null | undefined;
};
type ObjectStatusModelPageResponse = Api.Infra.PageResult<ObjectStatusModelResponse>;
type ObjectStatusTransitionPageResponse = Api.Infra.PageResult<ObjectStatusTransitionResponse>;
function createBatchDeleteQuery(ids: string[]) {
const query = new URLSearchParams();
ids.forEach(id => {
query.append('ids', id);
});
return query.toString();
}
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
return false;
}
return true;
}
return false;
}
function normalizeObjectStatusModel(model: ObjectStatusModelResponse): Api.Infra.ObjectStatusModel {
return {
...model,
id: normalizeStringId(model.id),
initialFlag: normalizeBooleanFlag(model.initialFlag),
terminalFlag: normalizeBooleanFlag(model.terminalFlag),
allowEdit: normalizeBooleanFlag(model.allowEdit),
progressExcludedFlag: normalizeBooleanFlag(model.progressExcludedFlag),
allowCreateProject: normalizeBooleanFlag(model.allowCreateProject),
allowCreateRequirement: normalizeBooleanFlag(model.allowCreateRequirement)
};
}
function normalizeObjectStatusTransition(transition: ObjectStatusTransitionResponse): Api.Infra.ObjectStatusTransition {
return {
...transition,
id: normalizeStringId(transition.id),
needReason: normalizeBooleanFlag(transition.needReason)
};
}
export async function fetchGetObjectStatusModelPage(params?: Api.Infra.ObjectStatusModelSearchParams) {
const result = await request<ObjectStatusModelPageResponse>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_MODEL_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelPageResponse>, data => ({
...data,
list: data.list.map(normalizeObjectStatusModel)
}));
}
export async function fetchGetObjectStatusModel(id: string) {
const result = await request<ObjectStatusModelResponse>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_MODEL_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelResponse>, normalizeObjectStatusModel);
}
export async function fetchCreateObjectStatusModel(data: Api.Infra.SaveObjectStatusModelParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_MODEL_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdateObjectStatusModel(data: { id: string } & Api.Infra.SaveObjectStatusModelParams) {
return request<boolean>({
url: `${OBJECT_STATUS_MODEL_PREFIX}/update`,
method: 'put',
data
});
}
export function fetchDeleteObjectStatusModel(id: string) {
return request<boolean>({
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
export function fetchBatchDeleteObjectStatusModel(ids: string[]) {
return request<boolean>({
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
method: 'delete'
});
}
export async function fetchGetObjectStatusTransitionPage(params?: Api.Infra.ObjectStatusTransitionSearchParams) {
const result = await request<ObjectStatusTransitionPageResponse>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ObjectStatusTransitionPageResponse>, data => ({
...data,
list: data.list.map(normalizeObjectStatusTransition)
}));
}
export async function fetchGetObjectStatusTransition(id: string) {
const result = await request<ObjectStatusTransitionResponse>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(
result as ServiceRequestResult<ObjectStatusTransitionResponse>,
normalizeObjectStatusTransition
);
}
export async function fetchCreateObjectStatusTransition(data: Api.Infra.SaveObjectStatusTransitionParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdateObjectStatusTransition(data: { id: string } & Api.Infra.SaveObjectStatusTransitionParams) {
return request<boolean>({
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/update`,
method: 'put',
data
});
}
export function fetchDeleteObjectStatusTransition(id: string) {
return request<boolean>({
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
export function fetchBatchDeleteObjectStatusTransition(ids: string[]) {
return request<boolean>({
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
method: 'delete'
});
}

28
src/service/api/notice.ts Normal file
View File

@@ -0,0 +1,28 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTICE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notice`;
type NoticeResponse = Omit<Api.Notice.Notice, 'id'> & {
id: string | number;
};
function normalizeNotice(data: NoticeResponse): Api.Notice.Notice {
return {
...data,
id: normalizeStringId(data.id)
};
}
/** 获取最近公告status=0按 id 倒序;登录即可,工作台公告卡片用) */
export async function fetchGetRecentNotices(size?: number) {
const result = await request<NoticeResponse[]>({
url: `${NOTICE_PREFIX}/recent`,
method: 'get',
params: { size },
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<NoticeResponse[]>, data => data.map(normalizeNotice));
}

View File

@@ -0,0 +1,60 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id'> & {
id: string | number;
};
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
list: NotifyMessageResponse[];
};
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
return {
...data,
id: normalizeStringId(data.id)
};
}
/** 获取当前用户未读站内信数量(铃铛红点轮询用) */
export function fetchGetUnreadNotifyCount() {
return request<number>({
url: `${NOTIFY_MESSAGE_PREFIX}/get-unread-count`,
method: 'get'
});
}
/** 分页获取我的站内信(消息列表唯一数据源;未读传 readStatus=false、已读传 true */
export async function fetchGetMyNotifyMessagePage(params: Api.NotifyMessage.MyPageParams) {
const result = await request<MyNotifyMessagePageResponse>({
url: `${NOTIFY_MESSAGE_PREFIX}/my-page`,
method: 'get',
params,
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<MyNotifyMessagePageResponse>, data => ({
...data,
list: data.list.map(normalizeNotifyMessage)
}));
}
/** 批量标记站内信已读(后端幂等:重复提交、非本人条目均安全) */
export function fetchUpdateNotifyMessageRead(ids: string[]) {
// 后端约定 ids 逗号分隔
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-read?ids=${ids.join(',')}`,
method: 'put'
});
}
/** 当前用户全部站内信标记已读 */
export function fetchUpdateAllNotifyMessageRead() {
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-all-read`,
method: 'put'
});
}

View File

@@ -0,0 +1,298 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
type StringIdResponse = string | number;
type OvertimeApplicationResponse = Omit<
Api.OvertimeApplication.OvertimeApplication,
'id' | 'applicantId' | 'approverId' | 'overtimeDate' | 'allowEdit' | 'terminal'
> & {
id: StringIdResponse;
applicantId: StringIdResponse;
approverId: StringIdResponse;
overtimeDate: ProjectLocalDateValue;
allowEdit?: boolean | number | string | null;
terminal?: boolean | number | string | null;
};
type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeApplicationPageResult, 'total' | 'list'> & {
total: number | string;
list: OvertimeApplicationResponse[];
};
type OvertimeApplicationApprovalRecordResponse = Omit<
Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
overtimeApplicationId: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeOvertimeApplication(
response: OvertimeApplicationResponse
): Api.OvertimeApplication.OvertimeApplication {
return {
...response,
id: normalizeStringId(response.id),
applicantId: normalizeStringId(response.applicantId),
approverId: normalizeStringId(response.approverId),
overtimeDate: normalizeProjectLocalDate(response.overtimeDate) ?? '',
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
approvalComment: response.approvalComment ?? null,
approvalTime: response.approvalTime ?? null
};
}
function normalizeApprovalRecord(
response: OvertimeApplicationApprovalRecordResponse
): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
opinion: response.opinion ?? null
};
}
function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
if (params.keyword) {
query.append('keyword', params.keyword);
}
if (params.applicantName) {
query.append('applicantName', params.applicantName);
}
if (params.approverId) {
query.append('approverId', params.approverId);
}
if (params.approverName) {
query.append('approverName', params.approverName);
}
if (params.statusCode) {
query.append('statusCode', params.statusCode);
}
params.overtimeDate?.forEach(item => {
if (item) {
query.append('overtimeDate', item);
}
});
params.createTime?.forEach(item => {
if (item) {
query.append('createTime', item);
}
});
return query.toString();
}
function toSaveRequest(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
return {
overtimeDate: data.overtimeDate,
overtimeDuration: data.overtimeDuration,
overtimeReason: data.overtimeReason.trim(),
overtimeContent: data.overtimeContent.trim(),
approverId: data.approverId
};
}
function toStatusActionRequest(data: Api.OvertimeApplication.StatusActionParams = {}) {
return {
reason: data.reason?.trim() || undefined
};
}
export async function fetchGetOvertimeApplicationPage(
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
) {
const query = createPageQuery(params);
const result = await request<OvertimeApplicationPageResponse>({
...safeJsonRequestConfig,
url: query ? `${OVERTIME_APPLICATION_PREFIX}/page?${query}` : `${OVERTIME_APPLICATION_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeOvertimeApplication)
}));
}
export async function fetchGetOvertimeApplicationApprovalPage(
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
) {
const query = createPageQuery(params);
const result = await request<OvertimeApplicationPageResponse>({
...safeJsonRequestConfig,
url: query
? `${OVERTIME_APPLICATION_PREFIX}/approval-page?${query}`
: `${OVERTIME_APPLICATION_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeOvertimeApplication)
}));
}
export async function fetchGetOvertimeApplicationDetail(id: string) {
const result = await request<OvertimeApplicationResponse>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationResponse>, normalizeOvertimeApplication);
}
export async function fetchCreateOvertimeApplication(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: OVERTIME_APPLICATION_PREFIX,
method: 'post',
data: toSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdateRejectedOvertimeApplication(
id: string,
data: Api.OvertimeApplication.SaveOvertimeApplicationParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'put',
data: toSaveRequest(data)
});
}
export function fetchApproveOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchBatchApproveOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
method: 'post',
data
});
}
export function fetchBatchRejectOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
method: 'post',
data
});
}
export function fetchDeleteOvertimeApplication(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'delete'
});
}
export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
data.map(normalizeApprovalRecord)
);
}
export async function fetchGetOvertimeApplicationStatusDict() {
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
data => data
);
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${OVERTIME_APPLICATION_PREFIX}/export?${query}` : `${OVERTIME_APPLICATION_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}

View File

@@ -0,0 +1,880 @@
import dayjs from 'dayjs';
import type { ConfigType } from 'dayjs';
import type { FlatResponseData } from '@sa/axios';
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ProjectExecutionResponse,
type TaskWorklogResponse,
normalizeProjectLocalDate,
normalizeTaskWorklog
} from './project-shared';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
type PersonalItemRecord = Api.PersonalItem.PersonalItem;
type PersonalItemWorklogRecord = Api.Project.TaskWorklog;
type PersonalItemResult<T> = Promise<FlatResponseData<any, T>>;
type StringIdResponse = string | number;
type PersonalItemLocalDateValue = string | number[] | null;
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: StringIdResponse;
id?: StringIdResponse;
};
type PersonalItemLifecycleActionResponse = Omit<Api.PersonalItem.PersonalItemLifecycleAction, 'needReason'> & {
needReason?: boolean | number | string | null;
};
type PersonalItemResponse = Omit<
Api.PersonalItem.PersonalItem,
| 'id'
| 'ownerId'
| 'terminal'
| 'allowEdit'
| 'availableActions'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'attachments'
| 'totalSpentHours'
> & {
id: StringIdResponse;
ownerId: StringIdResponse;
terminal?: boolean | number | string | null;
allowEdit?: boolean | number | string | null;
availableActions?: PersonalItemLifecycleActionResponse[] | null;
plannedStartDate?: PersonalItemLocalDateValue;
plannedEndDate?: PersonalItemLocalDateValue;
actualStartDate?: PersonalItemLocalDateValue;
actualEndDate?: PersonalItemLocalDateValue;
attachments?: AttachmentItemResponse[] | null;
progressRate?: number | null;
totalSpentHours?: number | string | null;
};
type PersonalItemPageResponse = Omit<Api.PersonalItem.PersonalItemPageResult, 'total' | 'list'> & {
total: number | string;
list: PersonalItemResponse[];
};
type PersonalItemWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
type PersonalItemExecutionOptionResponse = ProjectExecutionResponse & {
projectName?: string | null;
};
type PersonalItemSaveRequest = {
executionId?: string;
taskTitle: string;
type: string;
progressRate?: number;
plannedStartDate?: string;
plannedEndDate?: string;
taskDesc?: string;
attachments?: Array<{
id?: string;
url: string;
name: string;
size?: number;
contentType?: string;
}>;
};
type PersonalItemWorklogSaveRequest = {
startDate: string;
endDate: string;
durationHours: number;
progressRate: number;
workContent?: string;
attachments?: Array<{
id?: string;
url: string;
name: string;
size?: number;
contentType?: string;
}>;
difficulty: string;
};
const PERSONAL_ITEM_PREFIX = `${WEB_SERVICE_PREFIX}/project/personal-items`;
const CURRENT_USER_ID = 'current-user';
const CURRENT_USER_NAME = '当前用户';
const personalItems: PersonalItemRecord[] = createSeedItems();
const personalItemWorklogs: PersonalItemWorklogRecord[] = createSeedWorklogs();
const executionOptions: Api.PersonalItem.PersonalItemExecutionOption[] = createExecutionOptions();
function createSuccessResult<T>(data: T): PersonalItemResult<T> {
return Promise.resolve({
data,
error: null,
response: undefined
} as unknown as FlatResponseData<any, T>);
}
function normalizePageTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
return false;
}
return true;
}
return false;
}
function normalizeLifecycleActions(
actions?: PersonalItemLifecycleActionResponse[] | null
): Api.PersonalItem.PersonalItemLifecycleAction[] {
return (actions ?? []).map(action => ({
actionCode: action.actionCode,
actionName: action.actionName ?? '',
needReason: normalizeBooleanFlag(action.needReason)
}));
}
function normalizePersonalItem(response: PersonalItemResponse): Api.PersonalItem.PersonalItem {
return {
id: normalizeStringId(response.id),
taskTitle: response.taskTitle ?? '',
type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId),
statusCode: response.statusCode,
terminal: normalizeBooleanFlag(response.terminal),
allowEdit: normalizeBooleanFlag(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions),
progressRate:
typeof response.progressRate === 'number' ? response.progressRate : Number(response.progressRate ?? 0),
totalSpentHours: (() => {
if (typeof response.totalSpentHours === 'number') {
return response.totalSpentHours;
}
if (response.totalSpentHours === null || response.totalSpentHours === undefined) {
return null;
}
return Number(response.totalSpentHours);
})(),
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null,
attachments: normalizeAttachments(response.attachments),
creator: response.creator ?? '',
createTime: response.createTime ?? '',
updater: response.updater ?? '',
updateTime: response.updateTime ?? '',
deleted: Boolean(response.deleted),
ownerName: response.ownerName ?? null,
ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null
};
}
function normalizePersonalItemExecutionOption(
response: PersonalItemExecutionOptionResponse
): Api.PersonalItem.PersonalItemExecutionOption {
return {
executionId: normalizeStringId(response.id),
executionName: response.executionName ?? '',
projectId: normalizeStringId(response.projectId),
projectName: response.projectName ?? null
};
}
function toPersonalItemSaveRequest(data: Api.PersonalItem.SavePersonalItemParams): PersonalItemSaveRequest {
return {
executionId: data.executionId ?? undefined,
taskTitle: data.taskTitle.trim(),
type: data.type,
progressRate: typeof data.progressRate === 'number' ? data.progressRate : undefined,
plannedStartDate: data.plannedStartDate ?? undefined,
plannedEndDate: data.plannedEndDate ?? undefined,
taskDesc: data.taskDesc ?? undefined,
attachments:
data.attachments?.map(item => ({
id: item.fileId || undefined,
url: item.url,
name: item.name,
size: item.size,
contentType: item.contentType
})) ?? undefined
};
}
function toPersonalItemWorklogSaveRequest(
data: Api.PersonalItem.SavePersonalItemWorklogParams
): PersonalItemWorklogSaveRequest {
return {
startDate: data.startDate,
endDate: data.endDate,
durationHours: Number(data.durationHours.toFixed(1)),
progressRate: Number(data.progressRate.toFixed(2)),
workContent: data.workContent ?? undefined,
attachments:
data.attachments?.map(item => ({
id: item.fileId || undefined,
url: item.url,
name: item.name,
size: item.size,
contentType: item.contentType
})) ?? undefined,
difficulty: data.difficulty
};
}
function createPersonalItemPageQuery(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
if (params.keyword) {
query.append('keyword', params.keyword);
}
if (params.ownerId) {
query.append('ownerId', params.ownerId);
}
if (params.statusCode) {
query.append('statusCode', params.statusCode);
}
params.updateTime?.forEach(item => {
if (item) {
query.append('updateTime', item);
}
});
return query.toString();
}
function createIdsQuery(ids: string[]) {
const query = new URLSearchParams();
ids.forEach(id => {
if (id) {
query.append('ids', id);
}
});
return query.toString();
}
function createBindExecutionQuery(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
const query = new URLSearchParams();
payload.ids.forEach(id => {
if (id) {
query.append('itemIds', id);
}
});
query.append('executionId', payload.executionId);
return query.toString();
}
function cloneAttachment(item: Api.Project.AttachmentItem): Api.Project.AttachmentItem {
return { ...item };
}
function cloneItem(item: PersonalItemRecord): PersonalItemRecord {
return {
...item,
attachments: item.attachments?.map(cloneAttachment) ?? null
};
}
function cloneWorklog(item: PersonalItemWorklogRecord): PersonalItemWorklogRecord {
return {
...item,
attachments: item.attachments?.map(cloneAttachment) ?? null
};
}
function normalizeDateTime(value?: ConfigType | null) {
const target = value ? dayjs(value) : dayjs();
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD HH:mm:ss');
}
function normalizeDate(value?: ConfigType | null) {
if (!value) {
return null;
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD') : null;
}
function createSeedItems(): PersonalItemRecord[] {
const now = dayjs();
return [
{
id: 'personal-item-1',
taskTitle: '整理供应商沟通纪要',
type: 'daily',
ownerId: CURRENT_USER_ID,
statusCode: 'active',
progressRate: 45,
plannedStartDate: normalizeDate(now.subtract(3, 'day')),
plannedEndDate: normalizeDate(now.add(2, 'day')),
actualStartDate: normalizeDate(now.subtract(2, 'day')),
actualEndDate: null,
taskDesc: '<p>补齐今天会议纪要,沉淀成一页内部记录,便于后续同步。</p>',
lastStatusReason: null,
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(3, 'day').hour(9).minute(20).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(2, 'hour')),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '进行中'
},
{
id: 'personal-item-2',
taskTitle: '清理浏览器收藏夹里的项目入口',
type: 'daily',
ownerId: CURRENT_USER_ID,
statusCode: 'pending',
progressRate: 0,
plannedStartDate: normalizeDate(now.add(1, 'day')),
plannedEndDate: normalizeDate(now.add(4, 'day')),
actualStartDate: null,
actualEndDate: null,
taskDesc: '<p>把已经废弃的测试环境、旧文档入口统一清理。</p>',
lastStatusReason: null,
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(2, 'day').hour(14).minute(10).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(5, 'hour')),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '待处理'
},
{
id: 'personal-item-3',
taskTitle: '补充账号开通说明截图',
type: 'support',
ownerId: CURRENT_USER_ID,
statusCode: 'completed',
progressRate: 100,
plannedStartDate: normalizeDate(now.subtract(5, 'day')),
plannedEndDate: normalizeDate(now.subtract(2, 'day')),
actualStartDate: normalizeDate(now.subtract(5, 'day')),
actualEndDate: normalizeDate(now.subtract(1, 'day')),
taskDesc: '<p>为新同事入职说明补一版截图,后续发在群公告。</p>',
lastStatusReason: '已完成并同步团队',
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(5, 'day').hour(11).minute(0).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(30).second(0)),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '已完成'
}
];
}
function createSeedWorklogs(): PersonalItemWorklogRecord[] {
const now = dayjs();
return [
{
id: 'worklog-1',
taskId: 'personal-item-1',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(2, 'day'))!,
endDate: normalizeDate(now.subtract(2, 'day'))!,
durationHours: 2.5,
progressRate: 30,
difficulty: '2',
workContent: '整理会议录音和重点结论,先输出初版纪要。',
attachments: null,
createTime: normalizeDateTime(now.subtract(2, 'day').hour(19)),
updateTime: normalizeDateTime(now.subtract(2, 'day').hour(19))
},
{
id: 'worklog-2',
taskId: 'personal-item-1',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(1, 'day'))!,
endDate: normalizeDate(now.subtract(1, 'day'))!,
durationHours: 1.5,
progressRate: 45,
difficulty: '2',
workContent: '补全供应商待确认项并整理后续跟进人。',
attachments: null,
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18)),
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18))
},
{
id: 'worklog-3',
taskId: 'personal-item-3',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(5, 'day'))!,
endDate: normalizeDate(now.subtract(5, 'day'))!,
durationHours: 1,
progressRate: 60,
difficulty: '1',
workContent: '补拍账号开通流程截图。',
attachments: null,
createTime: normalizeDateTime(now.subtract(5, 'day').hour(15)),
updateTime: normalizeDateTime(now.subtract(5, 'day').hour(15))
},
{
id: 'worklog-4',
taskId: 'personal-item-3',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(1, 'day'))!,
endDate: normalizeDate(now.subtract(1, 'day'))!,
durationHours: 0.5,
progressRate: 100,
difficulty: '1',
workContent: '校对文案并发到群公告。',
attachments: null,
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20)),
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20))
}
];
}
function createExecutionOptions(): Api.PersonalItem.PersonalItemExecutionOption[] {
return [
{
executionId: 'execution-1001',
executionName: '2026Q2 运营提效',
projectId: 'project-1001',
projectName: '运营中台优化'
},
{
executionId: 'execution-1002',
executionName: '2026Q2 用户支持专项',
projectId: 'project-1002',
projectName: '基础平台升级'
},
{
executionId: 'execution-1003',
executionName: '2026Q3 数据治理',
projectId: 'project-1003',
projectName: '数据资产规范化'
}
];
}
function findItemIndex(id: string) {
return personalItems.findIndex(item => item.id === id);
}
function getItemOrThrow(id: string) {
const item = personalItems.find(current => current.id === id && !current.deleted);
if (!item) {
throw new Error(`personal item not found: ${id}`);
}
return item;
}
function sortItems(list: PersonalItemRecord[]) {
return [...list].sort((left, right) => dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf());
}
function sortWorklogs(list: PersonalItemWorklogRecord[]) {
return [...list].sort((left, right) => {
const endDiff = dayjs(right.endDate).valueOf() - dayjs(left.endDate).valueOf();
if (endDiff !== 0) {
return endDiff;
}
return dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf();
});
}
function getPersonalItemStatusName(statusCode: Api.PersonalItem.PersonalItemStatusCode) {
const statusNameMap: Partial<Record<Api.PersonalItem.PersonalItemStatusCode, string>> = {
pending: '待处理',
active: '进行中',
completed: '已完成'
};
return statusNameMap[statusCode] || statusCode;
}
function removeItemsByIds(ids: string[]) {
const idSet = new Set(ids);
for (let i = personalItems.length - 1; i >= 0; i -= 1) {
if (idSet.has(personalItems[i].id)) {
personalItems.splice(i, 1);
}
}
for (let i = personalItemWorklogs.length - 1; i >= 0; i -= 1) {
if (idSet.has(personalItemWorklogs[i].taskId)) {
personalItemWorklogs.splice(i, 1);
}
}
}
function sumWorklogHours(logs: PersonalItemWorklogRecord[]) {
return logs.reduce((sum, log) => sum + (log.durationHours ?? 0), 0);
}
function syncItemFromWorklogs(itemId: string) {
const item = getItemOrThrow(itemId);
const logs = sortWorklogs(personalItemWorklogs.filter(log => log.taskId === itemId));
item.statusName = getPersonalItemStatusName(item.statusCode);
item.totalSpentHours = sumWorklogHours(logs);
if (logs.length === 0) {
if (item.statusCode !== 'completed') {
item.progressRate = 0;
item.actualStartDate = null;
item.actualEndDate = null;
}
return;
}
const latestLog = logs[0];
const chronologicalLogs = [...logs].sort(
(left, right) => dayjs(left.startDate).valueOf() - dayjs(right.startDate).valueOf()
);
item.progressRate = latestLog.progressRate ?? item.progressRate;
item.actualStartDate = chronologicalLogs[0]?.startDate ?? item.actualStartDate;
item.actualEndDate = latestLog.endDate ?? item.actualEndDate;
item.updateTime = latestLog.updateTime;
item.updater = CURRENT_USER_NAME;
if (item.statusCode === 'pending') {
item.statusCode = 'active';
item.statusName = getPersonalItemStatusName(item.statusCode);
}
}
function applySaveFields(target: PersonalItemRecord, payload: Api.PersonalItem.SavePersonalItemParams) {
target.taskTitle = payload.taskTitle.trim();
target.type = payload.type;
target.ownerId = payload.ownerId || target.ownerId;
target.ownerName = CURRENT_USER_NAME;
target.plannedStartDate = payload.plannedStartDate;
target.plannedEndDate = payload.plannedEndDate;
target.taskDesc = payload.taskDesc ?? null;
target.attachments = payload.attachments?.map(cloneAttachment) ?? null;
target.updater = CURRENT_USER_NAME;
target.updateTime = normalizeDateTime();
}
function filterWorklogs(taskId: string, params?: Api.PersonalItem.PersonalItemWorklogSearchParams) {
return sortWorklogs(
personalItemWorklogs.filter(item => {
if (item.taskId !== taskId) {
return false;
}
if (params?.userId && item.userId !== params.userId) {
return false;
}
if (params?.startDate && dayjs(item.endDate).isBefore(dayjs(params.startDate), 'day')) {
return false;
}
if (params?.endDate && dayjs(item.startDate).isAfter(dayjs(params.endDate), 'day')) {
return false;
}
return true;
})
);
}
export async function fetchGetPersonalItemPage(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
const query = createPersonalItemPageQuery(params);
const result = await request<PersonalItemPageResponse>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/page?${query}` : `${PERSONAL_ITEM_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemPageResponse>, data => ({
total: normalizePageTotal(data.total),
list: data.list.map(normalizePersonalItem)
}));
}
export async function fetchGetPersonalItemDetail(id: string) {
const result = await request<PersonalItemResponse>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemResponse>, normalizePersonalItem);
}
export async function fetchCreatePersonalItem(data: Api.PersonalItem.SavePersonalItemParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: PERSONAL_ITEM_PREFIX,
method: 'post',
data: toPersonalItemSaveRequest(data)
});
const mapped = mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
if (!mapped.error && mapped.data) {
const now = normalizeDateTime();
const createdItem: PersonalItemRecord = {
id: mapped.data,
taskTitle: data.taskTitle.trim(),
type: data.type,
ownerId: data.ownerId || CURRENT_USER_ID,
statusCode: 'pending',
progressRate: typeof data.progressRate === 'number' ? data.progressRate : 0,
plannedStartDate: data.plannedStartDate,
plannedEndDate: data.plannedEndDate,
actualStartDate: null,
actualEndDate: null,
taskDesc: data.taskDesc ?? null,
lastStatusReason: null,
attachments: data.attachments?.map(cloneAttachment) ?? null,
creator: CURRENT_USER_NAME,
createTime: now,
updater: CURRENT_USER_NAME,
updateTime: now,
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: getPersonalItemStatusName('pending')
};
personalItems.unshift(createdItem);
}
return mapped;
}
export async function fetchUpdatePersonalItem(data: Api.PersonalItem.UpdatePersonalItemParams) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${data.id}`,
method: 'put',
data: toPersonalItemSaveRequest(data)
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
const targetIndex = findItemIndex(data.id);
if (targetIndex >= 0) {
applySaveFields(personalItems[targetIndex], data);
}
}
return mapped;
}
export async function fetchChangePersonalItemStatus(id: string, data: Api.PersonalItem.ChangePersonalItemStatusParams) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${id}/change-status`,
method: 'post',
data: {
actionCode: data.actionCode,
reason: data.reason ?? undefined
}
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
const target = personalItems.find(item => item.id === id);
if (target) {
target.lastStatusReason = data.reason ?? null;
target.updater = CURRENT_USER_NAME;
target.updateTime = normalizeDateTime();
if (data.actionCode === 'start') {
target.statusCode = 'active';
target.statusName = getPersonalItemStatusName('active');
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = null;
} else if (data.actionCode === 'complete') {
target.statusCode = 'completed';
target.statusName = getPersonalItemStatusName('completed');
target.progressRate = 100;
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = normalizeDate(dayjs());
} else if (data.actionCode === 'reopen') {
target.statusCode = 'active';
target.statusName = getPersonalItemStatusName('active');
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = null;
}
}
}
return mapped;
}
export async function fetchDeletePersonalItem(id: string) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/delete`,
method: 'delete',
params: { id }
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
removeItemsByIds([id]);
}
return mapped;
}
export async function fetchBatchDeletePersonalItems(payload: Api.PersonalItem.BatchDeletePersonalItemParams) {
const query = createIdsQuery(payload.ids);
const result = await request<boolean>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/delete-list?${query}` : `${PERSONAL_ITEM_PREFIX}/delete-list`,
method: 'delete'
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
removeItemsByIds(payload.ids);
}
return mapped;
}
export async function fetchGetPersonalItemExecutionOptions() {
const result = await request<PersonalItemExecutionOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/owner/all-execution`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemExecutionOptionResponse[]>, data =>
data.map(normalizePersonalItemExecutionOption)
);
}
export async function fetchBindPersonalItemsToExecution(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
const query = createBindExecutionQuery(payload);
const result = await request<boolean>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/relate-execution?${query}` : `${PERSONAL_ITEM_PREFIX}/relate-execution`,
method: 'post'
});
return mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
}
export function fetchStartPersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'start' }) as PersonalItemResult<boolean>;
}
export function fetchCompletePersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'complete' }) as PersonalItemResult<boolean>;
}
export function fetchReopenPersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'reopen' }) as PersonalItemResult<boolean>;
}
export async function fetchGetPersonalItemWorklogPage(
taskId: string,
params: Api.PersonalItem.PersonalItemWorklogSearchParams = {}
) {
const result = await request<PersonalItemWorklogPageResponse>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemWorklogPageResponse>, data => ({
...data,
list: data.list.map(normalizeTaskWorklog)
}));
}
export async function fetchCreatePersonalItemWorklog(
taskId: string,
data: Api.PersonalItem.SavePersonalItemWorklogParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
method: 'post',
data: toPersonalItemWorklogSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdatePersonalItemWorklog(
taskId: string,
payload: { worklogId: string; data: Api.PersonalItem.SavePersonalItemWorklogParams }
): PersonalItemResult<boolean> {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${payload.worklogId}`,
method: 'put',
data: toPersonalItemWorklogSaveRequest(payload.data)
});
}
export function fetchDeletePersonalItemWorklog(taskId: string, worklogId: string): PersonalItemResult<boolean> {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${worklogId}`,
method: 'delete'
});
}

View File

@@ -91,7 +91,7 @@ function createProductActivityTimelinePageQuery(params: Api.Product.ProductActiv
return query.toString(); return query.toString();
} }
/** 鑾峰彇浜у搧鍒嗛〉 */ /** 获取产品分页 */
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) { export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
const result = await request<ProductPageResponse>({ const result = await request<ProductPageResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
@@ -106,16 +106,37 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
})); }));
} }
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Product.OverviewStatusItem[] | null;
};
/** 归一化产品概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取产品入口页概览统计 */ /** 获取产品入口页概览统计 */
export function fetchGetProductOverviewSummary() { export async function fetchGetProductOverviewSummary() {
return request<Api.Product.ProductOverviewSummary>({ const result = await request<ProductOverviewSummaryResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/overview-summary`, url: `${PRODUCT_PREFIX}/overview-summary`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
normalizeProductOverviewSummary
);
} }
/** 鑾峰彇浜у搧璇︽儏 */ /** 获取产品详情 */
export async function fetchGetProduct(id: string) { export async function fetchGetProduct(id: string) {
const result = await request<ProductResponse>({ const result = await request<ProductResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
@@ -127,7 +148,7 @@ export async function fetchGetProduct(id: string) {
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct); return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
} }
/** 鍒涘缓浜у搧 */ /** 新增产品 */
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) { export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
const result = await request<string | number>({ const result = await request<string | number>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
@@ -139,7 +160,19 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId); return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
} }
/** 鏇存柊浜у搧 */ /** 创建产品(含初始团队,原子接口) */
export async function fetchCreateProductWithTeam(data: Api.Product.CreateProductWithTeamParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/create-with-team`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新产品 */
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) { export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
return request<boolean>({ return request<boolean>({
url: `${PRODUCT_PREFIX}/update`, url: `${PRODUCT_PREFIX}/update`,
@@ -148,7 +181,7 @@ export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
}); });
} }
/** 鍙樻洿浜у搧鐘舵€? */ /** 改变产品状态 */
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) { export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
return request<boolean>({ return request<boolean>({
url: `${PRODUCT_PREFIX}/change-status`, url: `${PRODUCT_PREFIX}/change-status`,
@@ -157,7 +190,7 @@ export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusPa
}); });
} }
/** 鍒犻櫎浜у搧 */ /** 删除产品 */
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) { export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
return request<boolean>({ return request<boolean>({
url: `${PRODUCT_PREFIX}/delete`, url: `${PRODUCT_PREFIX}/delete`,
@@ -171,7 +204,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit< type RequirementResponse = Omit<
Api.Product.Requirement, Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId' | 'id'
| 'parentId'
| 'moduleId'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'sourceBizId'
| 'attachments'
> & { > & {
id: string | number; id: string | number;
parentId: string | number; parentId: string | number;
@@ -179,11 +219,68 @@ type RequirementResponse = Omit<
proposerId: string | number; proposerId: string | number;
currentHandlerUserId?: string | number | null; currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null; implementProjectId?: string | number | null;
implementProjectName?: string | null;
sourceBizId?: string | number | null; sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: RequirementResponse[]; children?: RequirementResponse[];
}; };
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>; type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
type RequirementReviewResponse = Omit<
Api.Product.RequirementReview,
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
> & {
id: string | number;
requirementId: string | number;
operatorId: string | number;
attendees?: Array<{
userId: string | number;
nickname: string;
}>;
attachments?: AttachmentItemResponse[] | null;
};
type ProductRequirementDashboardSummaryResponse = {
total?: number | string | null;
todo?: number | string | null;
pendingClaim?: number | string | null;
pendingReview?: number | string | null;
pendingDispatch?: number | string | null;
completed?: number | string | null;
completionRate?: number | string | null;
highPriorityTodo?: number | string | null;
};
type ProductRequirementDashboardRecentChangeResponse = Omit<
Api.Product.ProductRequirementDashboardRecentChange,
'id' | 'requirementId' | 'operatorUserId'
> & {
id: string | number;
requirementId?: string | number | null;
operatorUserId?: string | number | null;
};
type ProductRequirementDashboardResponse = {
summary?: ProductRequirementDashboardSummaryResponse | null;
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
};
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: string | number;
id?: string | number;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement { function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
return { return {
@@ -194,11 +291,58 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
proposerId: normalizeStringId(requirement.proposerId), proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId), implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeRequirement) children: requirement.children?.map(normalizeRequirement)
}; };
} }
function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview {
return {
...review,
id: normalizeStringId(review.id),
requirementId: normalizeStringId(review.requirementId),
operatorId: normalizeStringId(review.operatorId),
attendees: review.attendees?.map(item => ({
...item,
userId: normalizeStringId(item.userId)
})),
attachments: normalizeAttachments(review.attachments)
};
}
function normalizeDashboardCount(value: number | string | null | undefined) {
const count = Number(value ?? 0);
return Number.isFinite(count) ? Math.max(0, count) : 0;
}
function normalizeProductRequirementDashboard(
data: ProductRequirementDashboardResponse
): Api.Product.ProductRequirementDashboard {
const summary = data.summary ?? {};
return {
summary: {
total: normalizeDashboardCount(summary.total),
todo: normalizeDashboardCount(summary.todo),
pendingClaim: normalizeDashboardCount(summary.pendingClaim),
pendingReview: normalizeDashboardCount(summary.pendingReview),
pendingDispatch: normalizeDashboardCount(summary.pendingDispatch),
completed: normalizeDashboardCount(summary.completed),
completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)),
highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo)
},
recentChanges: (data.recentChanges ?? []).map(item => ({
...item,
id: normalizeStringId(item.id),
requirementId: normalizeNullableStringId(item.requirementId),
operatorUserId: normalizeNullableStringId(item.operatorUserId)
}))
};
}
/** 获取需求分页列表 */ /** 获取需求分页列表 */
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) { export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
const result = await request<RequirementPageResponse>({ const result = await request<RequirementPageResponse>({
@@ -294,17 +438,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId); return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
} }
/** 关闭需求 */
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/close`,
method: 'post',
data
});
}
/** 获取需求可执行的状态动作列表 */ /** 获取需求可执行的状态动作列表 */
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) { export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleAction[]>({ const result = await request<Api.Product.RequirementLifecycleAction[]>({
@@ -317,16 +450,62 @@ export async function fetchGetRequirementAllowedTransitions(requirementId: strin
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data); return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
} }
/** 获取需求生命周期信息 */ /** 批量获取需求可执行的状态动作列表 */
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) { export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Product.RequirementBatchReqVO) {
const result = await request<Api.Product.RequirementLifecycleInfo>({ const result = await request<Api.Product.RequirementAllowedTransitionBatchRespVO[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/lifecycle`, url: `${REQUIREMENT_PREFIX}/allowed-transitions/batch`,
method: 'get', method: 'post',
params: { requirementId, productId } data
}); });
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data); return mapServiceResult(
result as ServiceRequestResult<Api.Product.RequirementAllowedTransitionBatchRespVO[]>,
data1 =>
data1.map(item => ({
requirementId: normalizeStringId(item.requirementId),
transitions: item.transitions
}))
);
}
/** 提交产品需求评审 */
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/review/submit`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 获取产品需求评审记录 */
export async function fetchGetProductRequirementReview(productId: string, requirementId: string) {
const result = await request<RequirementReviewResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/review/get`,
method: 'get',
params: { productId, requirementId }
});
return mapServiceResult(result as ServiceRequestResult<RequirementReviewResponse>, normalizeRequirementReview);
}
/** 获取产品概览需求池实时看板 */
export async function fetchGetProductRequirementDashboard(productId: string) {
const result = await request<ProductRequirementDashboardResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/dashboard`,
method: 'get',
params: { productId }
});
return mapServiceResult(
result as ServiceRequestResult<ProductRequirementDashboardResponse>,
normalizeProductRequirementDashboard
);
} }
/** 获取需求所有状态字典 */ /** 获取需求所有状态字典 */
@@ -340,15 +519,41 @@ export async function fetchGetRequirementStatusDict() {
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data); return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
} }
/** 获取需求终止态状态字典 */ /** 判断产品需求是否已指派并生成项目需求 */
export async function fetchGetRequirementTerminalStatusDict() { export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementStatusDict[]>({ return request<boolean>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`, url: `${REQUIREMENT_PREFIX}/has-dispatched`,
method: 'get' method: 'get',
params: { requirementId, productId }
});
}
/** 批量判断产品需求是否已指派并生成项目需求 */
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/has-dispatched/batch`,
method: 'post',
data
}); });
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data); return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementHasDispatchedBatchRespVO[]>, data1 =>
data1.map(item => ({
requirementId: normalizeStringId(item.requirementId),
hasDispatched: Boolean(item.hasDispatched)
}))
);
}
/** 根据当前产品需求id获取对应地所流转到项目侧的项目需求id */
export async function fetchGetDispatchedProjectLink(productRequirementId: string) {
return request<{ projectRequirementId: string; projectId: string }>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/dispatched-project-link`,
method: 'get',
params: { productRequirementId }
});
} }
// ========== 模块管理 API ========== // ========== 模块管理 API ==========
@@ -475,6 +680,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId); return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
} }
export async function fetchBatchCreateProductMembers(id: string, data: Api.Product.BatchCreateProductMembersParams) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/batch`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
Array.isArray(list) ? list.map(normalizeStringId) : []
);
}
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) { export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
return request<boolean>({ return request<boolean>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
@@ -484,6 +702,15 @@ export function fetchUpdateProductMember(id: string, memberId: string, data: Api
}); });
} }
export function fetchBatchInactiveProductMembers(id: string, data: Api.Product.BatchInactiveProductMembersParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/batch/inactive`,
method: 'post',
data
});
}
export function fetchInactiveProductMember( export function fetchInactiveProductMember(
id: string, id: string,
memberId: string, memberId: string,

View File

@@ -0,0 +1,62 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
safeJsonRequestConfig
} from './shared';
import { type ProjectResponse, normalizeProject } from './project';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
/**
* group-page 原始响应。
* 组级 managerUserId、productId后端对小数值 Long如 1001仍按数字返回需 String() 归一;
* projects 字段与 page 接口项目行完全一致,复用 ProjectResponse / normalizeProject。
*/
type ProjectGroupResponse = Omit<Api.Project.ProjectGroup, 'productId' | 'managerUserId' | 'projects'> & {
productId?: string | number | null;
managerUserId?: string | number | null;
projects: ProjectResponse[];
};
type ProjectGroupPageResponse = Omit<Api.Project.ProjectGroupPageResult, 'list'> & {
list: ProjectGroupResponse[];
};
/** 归一化分组:组级 ID String 化,组内项目复用 normalizeProjectid/managerUserId/productId/日期统一口径) */
function normalizeProjectGroup(group: ProjectGroupResponse): Api.Project.ProjectGroup {
return {
...group,
productId: normalizeNullableStringId(group.productId),
managerUserId: normalizeNullableStringId(group.managerUserId),
projects: Array.isArray(group.projects) ? group.projects.map(normalizeProject) : []
};
}
/**
* 项目列表「按产品分组」分页。
*
* 后端契约见《项目列表产品分组-前端API-2026-06-10》
* - pageNo/pageSize 为产品组维度分页statusCode 不传 = 「全部」口径(后端从状态机推导,
* 当前等价 pending/active/paused/completed不含 cancelled/archived
* - 组内 projects 仅返前 topN 条(默认 5projectTotal 为该口径组内全量计数;
* 剩余项目由页面按 productId / orphanOnly + statusCodes 走 page 接口展开拉取。
* - typeCounts / hasBaseline 现状恒按「全部」口径统计,不随 statusCode 变化;其中 typeCounts 已提需求
* 改为与 projectTotal 同口径见《2026-06-11-项目分组接口typeCounts口径-后端接口需求》),后端落地后更新本注释;
* hasBaseline = 存在非已取消的主线项目(已归档/完成也算占坑),前端直接消费、不自行推导。
*/
export async function fetchGetProjectGroupPage(params?: Api.Project.ProjectGroupSearchParams) {
const result = await request<ProjectGroupPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/group-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectGroupPageResponse>, data => ({
...data,
list: Array.isArray(data.list) ? data.list.map(normalizeProjectGroup) : []
}));
}

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { normalizeNullableStringId, normalizeStringId } from './shared'; import { normalizeNullableStringId, normalizeStringId } from './shared';
type ProjectStatusCode = Api.Project.ProjectStatusCode; type ProjectStatusCode = Api.Project.ProjectStatusCode;
@@ -23,6 +24,8 @@ export type ProjectExecutionResponse = Omit<
| 'actualStartDate' | 'actualStartDate'
| 'actualEndDate' | 'actualEndDate'
| 'progressRate' | 'progressRate'
| 'priority'
| 'priorityName'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
projectId: StringIdResponse; projectId: StringIdResponse;
@@ -34,16 +37,108 @@ export type ProjectExecutionResponse = Omit<
actualStartDate?: ProjectLocalDateValue; actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue; actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null; progressRate?: number | null;
priority?: string | number | null;
priorityName?: string | null;
}; };
export type ExecutionMemberResponse = Omit<Api.Project.ExecutionMember, 'id' | 'executionId' | 'userId'> & { export type MyExecutionResponse = Omit<
Api.Project.MyExecutionItem,
| 'id'
| 'projectId'
| 'projectRequirementId'
| 'priority'
| 'progressRate'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectRequirementId?: StringIdResponse | null;
priority?: string | number | null;
progressRate?: number | null;
plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
};
export type MyParticipatedProjectResponse = Omit<Api.Project.MyParticipatedProjectItem, 'id'> & {
id: StringIdResponse;
};
export type MyOwnedProjectMemberResponse = Omit<Api.Project.MyOwnedProjectMember, 'userId'> & {
userId: StringIdResponse;
};
export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' | 'members'> & {
id: StringIdResponse;
members?: MyOwnedProjectMemberResponse[] | null;
};
export type MyTaskResponse = Omit<
Api.Project.MyTaskItem,
| 'id'
| 'projectId'
| 'executionId'
| 'priority'
| 'plannedEndDate'
| 'progressRate'
| 'createTime'
| 'parentTaskId'
| 'availableActions'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
executionId?: StringIdResponse | null;
priority?: string | number | null;
plannedEndDate?: ProjectLocalDateValue;
progressRate?: number | string | null;
createTime?: string | number | null;
parentTaskId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
};
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: TeamLoadDistributionItemResponse[] | null;
};
export type TeamLoadResponse = {
members?: TeamLoadMemberResponse[] | null;
};
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
dailyHours?: number[] | null;
distribution?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
members?: TeamWorklogWeekMemberResponse[] | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
userId: StringIdResponse; userId: StringIdResponse;
}; };
export type ExecutionMemberLogResponse = Omit< export type ExecutionAssigneeLogResponse = Omit<
Api.Project.ExecutionMemberLog, Api.Project.ExecutionAssigneeLog,
'id' | 'executionId' | 'userId' | 'operatorUserId' 'id' | 'executionId' | 'userId' | 'operatorUserId'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
@@ -52,6 +147,55 @@ export type ExecutionMemberLogResponse = Omit<
operatorUserId: StringIdResponse; operatorUserId: StringIdResponse;
}; };
type TaskAssigneeRefResponse = Omit<Api.Project.TaskAssigneeRef, 'id' | 'userId'> & {
id: StringIdResponse;
userId: StringIdResponse;
};
/**
* 后端 attachments 项的兼容形态:历史/当前响应字段名是 `id`,前端类型统一用 `fileId`。
* normalizeAttachments 负责把两者归一成 `fileId`。
*/
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: StringIdResponse;
id?: StringIdResponse;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
/**
* 5.6 单独接口返的协办人字段(与 5.3 嵌入字段命名口径不一致:返 userNickname 而非 nickname
* 经 normalizeTaskAssignee 归一化后对外统一为 Api.Project.TaskAssigneeRef。
*/
export type TaskAssigneeFromApiResponse = {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
userNickname?: string | null;
joinedAt?: string | null;
};
export type TaskAssigneeLogResponse = Omit<
Api.Project.TaskAssigneeLog,
'id' | 'taskId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
operatorUserId: StringIdResponse;
};
export type ProjectTaskResponse = Omit< export type ProjectTaskResponse = Omit<
Api.Project.ProjectTask, Api.Project.ProjectTask,
| 'id' | 'id'
@@ -59,24 +203,52 @@ export type ProjectTaskResponse = Omit<
| 'executionId' | 'executionId'
| 'parentTaskId' | 'parentTaskId'
| 'ownerId' | 'ownerId'
| 'executionOwnerId'
| 'parentTaskOwnerId'
| 'availableActions' | 'availableActions'
| 'plannedStartDate' | 'plannedStartDate'
| 'plannedEndDate' | 'plannedEndDate'
| 'actualStartDate' | 'actualStartDate'
| 'actualEndDate' | 'actualEndDate'
| 'progressRate' | 'progressRate'
| 'assignees'
| 'attachments'
| 'priority'
| 'priorityName'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
projectId: StringIdResponse; projectId: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
executionName?: string | null;
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
parentTaskId?: StringIdResponse | null; parentTaskId?: StringIdResponse | null;
ownerId: StringIdResponse; ownerId: StringIdResponse;
executionOwnerId?: StringIdResponse | null;
parentTaskOwnerId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null; availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
plannedStartDate?: ProjectLocalDateValue; plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue; plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue; actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue; actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null; progressRate?: number | null;
assignees?: TaskAssigneeRefResponse[] | null;
attachments?: AttachmentItemResponse[] | null;
totalSpentHours?: number | null;
priority?: string | number | null;
priorityName?: string | null;
};
export type TaskWorklogResponse = Omit<
Api.Project.TaskWorklog,
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
difficulty?: string | null;
attachments?: AttachmentItemResponse[] | null;
startDate?: ProjectLocalDateValue;
endDate?: ProjectLocalDateValue;
}; };
export interface ProjectMemberResponse { export interface ProjectMemberResponse {
@@ -146,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
return String(value); return String(value);
} }
/**
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
*/
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '';
}
let parsed: dayjs.Dayjs;
if (typeof value === 'number') {
parsed = dayjs(value);
} else if (/^\d+$/.test(value)) {
// 字符串形态的毫秒时间戳dayjs 无法直接解析,先转数值(时间值非 ID安全整数范围内
parsed = dayjs(Number(value));
} else {
parsed = dayjs(value);
}
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
}
export function normalizeLifecycleActions<ActionCode extends string>( export function normalizeLifecycleActions<ActionCode extends string>(
actions: LifecycleActionResponse<ActionCode>[] | null | undefined actions: LifecycleActionResponse<ActionCode>[] | null | undefined
): Api.Project.LifecycleAction<ActionCode>[] { ): Api.Project.LifecycleAction<ActionCode>[] {
@@ -172,12 +366,30 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
}; };
} }
function normalizePriority(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '1';
}
return String(value);
}
function normalizeProgressRate(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : null;
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution { export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId), projectId: normalizeStringId(response.projectId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId), projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
ownerId: normalizeStringId(response.ownerId), ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null, ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null, statusName: response.statusName ?? null,
@@ -189,12 +401,127 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
actualStartDate: normalizeProjectLocalDate(response.actualStartDate), actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate), actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0, progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
executionDesc: response.executionDesc ?? null, executionDesc: response.executionDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null lastStatusReason: response.lastStatusReason ?? null
}; };
} }
export function normalizeExecutionMember(response: ExecutionMemberResponse): Api.Project.ExecutionMember { export function normalizeMyExecution(response: MyExecutionResponse): Api.Project.MyExecutionItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null
};
}
export function normalizeMyParticipatedProject(
response: MyParticipatedProjectResponse
): Api.Project.MyParticipatedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
statusName: response.statusName ?? null,
myRole: response.myRole ?? null
};
}
export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.Project.MyOwnedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
myRole: response.myRole ?? null,
plannedEndDate: response.plannedEndDate ?? null,
members: (response.members ?? []).map(member => ({
...member,
userId: normalizeStringId(member.userId),
userName: member.userName ?? null
}))
};
}
export function normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeNullableStringId(response.executionId),
executionName: response.executionName ?? null,
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
createTime: normalizeProjectDateTime(response.createTime),
parentTaskId: normalizeNullableStringId(response.parentTaskId),
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions)
};
}
function normalizeWorklogDistributionItem(
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
return {
projectId: normalizeNullableStringId(response.projectId),
projectName: response.projectName ?? null,
kind: response.kind
};
}
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
return {
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
count: typeof item.count === 'number' ? item.count : 0
})),
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
}))
};
}
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
distribution: (response.distribution ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
};
}
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
}))
};
}
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
@@ -207,7 +534,9 @@ export function normalizeExecutionMember(response: ExecutionMemberResponse): Api
}; };
} }
export function normalizeExecutionMemberLog(response: ExecutionMemberLogResponse): Api.Project.ExecutionMemberLog { export function normalizeExecutionAssigneeLog(
response: ExecutionAssigneeLogResponse
): Api.Project.ExecutionAssigneeLog {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
@@ -226,9 +555,17 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId), projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId), executionId: normalizeStringId(response.executionId),
executionName: response.executionName ?? null,
executionStatusCode: response.executionStatusCode ?? null,
parentTaskId: normalizeNullableStringId(response.parentTaskId), parentTaskId: normalizeNullableStringId(response.parentTaskId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId), ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null, ownerNickname: response.ownerNickname ?? null,
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
statusName: response.statusName ?? null, statusName: response.statusName ?? null,
terminal: Boolean(response.terminal), terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit), allowEdit: Boolean(response.allowEdit),
@@ -238,7 +575,58 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate), plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate), actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate), actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
taskDesc: response.taskDesc ?? null, taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null lastStatusReason: response.lastStatusReason ?? null,
assignees:
response.assignees?.map(item => ({
id: normalizeStringId(item.id),
userId: normalizeStringId(item.userId),
nickname: item.nickname ?? ''
})) ?? null,
attachments: normalizeAttachments(response.attachments),
totalSpentHours: response.totalSpentHours ?? null
};
}
export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project.TaskWorklog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null,
workContent: response.workContent ?? null,
attachments: normalizeAttachments(response.attachments),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
// 后端 LocalDate 默认序列化为 [year, month, day] 数组,必须归一为 'YYYY-MM-DD' 字符串供 ElDatePicker 使用
startDate: normalizeProjectLocalDate(response.startDate) ?? '',
endDate: normalizeProjectLocalDate(response.endDate) ?? '',
// 历史记录或异常缺失时兜底为字典默认档位 "2"
difficulty: response.difficulty ?? '2',
difficultyName: response.difficultyName ?? null
};
}
export function normalizeTaskAssignee(response: TaskAssigneeFromApiResponse): Api.Project.TaskAssigneeRef {
return {
id: normalizeStringId(response.id),
userId: normalizeStringId(response.userId),
nickname: response.userNickname ?? '',
joinedAt: response.joinedAt ?? null
};
}
export function normalizeTaskAssigneeLog(response: TaskAssigneeLogResponse): Api.Project.TaskAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
operatorUserId: normalizeStringId(response.operatorUserId),
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
reason: response.reason ?? null
}; };
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import type { RouteMeta } from 'vue-router';
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types'; import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
import { objectContextDomainConfigs } from '@/constants/object-context'; import { objectContextDomainConfigs } from '@/constants/object-context';
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service'; import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';

View File

@@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) {
} }
/** 获取用户简单列表(用于用户选择下拉框) */ /** 获取用户简单列表(用于用户选择下拉框) */
export function fetchGetUserSimpleList() { export async function fetchGetUserSimpleList() {
return request<UserSimpleResponse[]>({ return request<UserSimpleResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_PREFIX}/simple-list`, url: `${USER_PREFIX}/simple-list`,
@@ -455,6 +455,19 @@ export function fetchGetUserSimpleList() {
); );
} }
/** 获取当前登录人的直属上级 */
export async function fetchGetLoginUserDirectManager() {
return request<UserSimpleResponse | null>({
...safeJsonRequestConfig,
url: `${USER_PREFIX}/profile/direct-manager`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse | null>, data =>
data ? normalizeUserSimple(data) : null
)
);
}
/** 获取用户分页 */ /** 获取用户分页 */
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) { export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({ return request<Api.SystemManage.UserList>({
@@ -669,7 +682,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
* - 中间节点:有上级也有下级 * - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级 * - 叶子节点:基层员工,没有下级
*/ */
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) { export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({ return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
@@ -686,7 +699,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
* 通过搜索框的查询条件,获取用户管理链路树形结构 * 通过搜索框的查询条件,获取用户管理链路树形结构
* 用于树形控件展示,包含用户的上下级层级关系 * 用于树形控件展示,包含用户的上下级层级关系
*/ */
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) { export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({ return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
@@ -706,7 +719,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
* *
* @param id 关系记录主键 ID * @param id 关系记录主键 ID
*/ */
export function fetchGetUserManagementRelation(id: string) { export async function fetchGetUserManagementRelation(id: string) {
return request<UserManagementRelationResponse>({ return request<UserManagementRelationResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
@@ -724,7 +737,7 @@ export function fetchGetUserManagementRelation(id: string) {
* *
* @param data 创建请求参数 * @param data 创建请求参数
*/ */
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) { export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
return request<string | number>({ return request<string | number>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
@@ -778,3 +791,20 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
method: 'delete' method: 'delete'
}); });
} }
/**
* 获取未绑定直属上级的候选下级用户列表
*
* 用于获取尚未绑定直属上级的用户列表,供选择使用
*
* @returns 候选下级用户列表
*/
export async function fetchGetCandidateSubordinateUsers() {
return request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/candidate-users`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
);
}

View File

@@ -0,0 +1,866 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const WORK_REPORT_PREFIX = `${WEB_SERVICE_PREFIX}/project/work-reports`;
const WEEKLY_PREFIX = `${WORK_REPORT_PREFIX}/weekly`;
const MONTHLY_PREFIX = `${WORK_REPORT_PREFIX}/monthly`;
const PROJECT_PREFIX = `${WORK_REPORT_PREFIX}/project`;
type StringIdResponse = string | number;
type MaybeStringIdResponse = string | number | null | undefined;
type PageResponse<T> = {
total: number | string;
list: T[];
};
type ReviewItemResponse = Omit<Api.WorkReport.Common.PersonalReportReviewItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type PlanItemResponse = Omit<Api.WorkReport.Common.PersonalReportPlanItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyTravelSegmentResponse = Omit<Api.WorkReport.Weekly.WeeklyReportTravelSegment, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyReportResponse = Omit<
Api.WorkReport.Weekly.WeeklyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems' | 'travelSegments'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
travelSegments?: WeeklyTravelSegmentResponse[] | null;
};
type MonthlyReportResponse = Omit<
Api.WorkReport.Monthly.MonthlyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
};
type MemberSnapshotResponse = Omit<Api.WorkReport.Project.WorkReportMemberSnapshot, 'userId'> & {
userId: StringIdResponse;
};
type ProjectReportItemResponse = Omit<Api.WorkReport.Project.ProjectReportItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type ProjectReportResponse = Omit<
Api.WorkReport.Project.ProjectReport,
'id' | 'projectId' | 'projectOwnerId' | 'projectMemberSnapshot' | 'supervisorUserId' | 'currentItems' | 'nextItems'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectOwnerId: StringIdResponse;
projectMemberSnapshot?: MemberSnapshotResponse[] | null;
supervisorUserId: StringIdResponse;
currentItems?: ProjectReportItemResponse[] | null;
nextItems?: ProjectReportItemResponse[] | null;
};
type ApprovalRecordResponse = Omit<
Api.WorkReport.Common.WorkReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type MonthlyApprovalRecordResponse = Omit<
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
id: StringIdResponse;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeApprovalConclusion(value: unknown) {
const conclusion = String(value || '')
.trim()
.toLowerCase();
if (conclusion === 'approve') return 'approved';
if (conclusion === 'reject') return 'rejected';
return conclusion;
}
function normalizeDateText(value: unknown) {
if (value === null || value === undefined) return undefined;
const text = String(value).trim();
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text || undefined;
}
function normalizeTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function sumWorkHours(items: Array<{ workHours?: number | string | null }> = []) {
return items.reduce((sum, item) => {
const value = Number(item.workHours ?? 0);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
}
function normalizeReportTotalWorkHours(
totalWorkHours: number | string | null | undefined,
fallbackTotalWorkHours: number
) {
const normalizedTotal = Number(totalWorkHours ?? 0);
if (
(totalWorkHours === null ||
totalWorkHours === undefined ||
totalWorkHours === '' ||
(Number.isFinite(normalizedTotal) && normalizedTotal === 0)) &&
fallbackTotalWorkHours > 0
) {
return fallbackTotalWorkHours;
}
return totalWorkHours ?? 0;
}
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
query.append(key, String(value));
}
function appendArray(query: URLSearchParams, key: string, values?: Array<string | null | undefined> | null) {
values?.forEach(value => appendValue(query, key, value));
}
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
const query = new URLSearchParams();
appendValue(query, 'pageNo', params.pageNo ?? 1);
appendValue(query, 'pageSize', params.pageSize ?? 10);
appendValue(query, 'keyword', params.keyword);
appendValue(query, 'statusCode', params.statusCode);
appendValue(query, 'supervisorName', params.supervisorName);
appendArray(query, 'periodStartDate', params.periodStartDate);
appendArray(query, 'submitTime', params.submitTime);
return query;
}
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
return query.toString();
}
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
return createBasePageQuery(params).toString();
}
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendValue(query, 'projectId', params.projectId);
appendValue(query, 'flag', params.flag);
return query.toString();
}
function normalizeReviewItem(item: ReviewItemResponse): Api.WorkReport.Common.PersonalReportReviewItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizePlanItem(item: PlanItemResponse): Api.WorkReport.Common.PersonalReportPlanItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeWeeklyTravelSegment(
item: WeeklyTravelSegmentResponse
): Api.WorkReport.Weekly.WeeklyReportTravelSegment {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined,
startDate: normalizeDateText(item.startDate),
endDate: normalizeDateText(item.endDate)
};
}
function normalizeWeeklyReport(response: WeeklyReportResponse): Api.WorkReport.Weekly.WeeklyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
isBusinessTrip: normalizeBooleanFlag(response.isBusinessTrip),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? [],
travelSegments: response.travelSegments?.map(normalizeWeeklyTravelSegment) ?? []
};
}
function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport.Monthly.MonthlyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? []
};
}
function normalizeMemberSnapshot(item: MemberSnapshotResponse): Api.WorkReport.Project.WorkReportMemberSnapshot {
return {
...item,
userId: normalizeStringId(item.userId)
};
}
function normalizeProjectReportItem(item: ProjectReportItemResponse): Api.WorkReport.Project.ProjectReportItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeProjectReport(response: ProjectReportResponse): Api.WorkReport.Project.ProjectReport {
const fallbackTotalWorkHours = sumWorkHours(response.currentItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
projectOwnerId: normalizeStringId(response.projectOwnerId),
projectMemberSnapshot: response.projectMemberSnapshot?.map(normalizeMemberSnapshot) ?? [],
supervisorUserId: normalizeStringId(response.supervisorUserId),
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
currentItems: response.currentItems?.map(normalizeProjectReportItem) ?? [],
nextItems: response.nextItems?.map(normalizeProjectReportItem) ?? []
};
}
function normalizeApprovalRecord(response: ApprovalRecordResponse): Api.WorkReport.Common.WorkReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeMonthlyApprovalRecord(
response: MonthlyApprovalRecordResponse
): Api.WorkReport.Monthly.MonthlyReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeProjectOption(
response: ProjectOptionResponse
): Api.WorkReport.Project.ProjectReportOwnerProjectOption {
return {
...response,
id: normalizeStringId(response.id)
};
}
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
return {
total: normalizeTotal(data.total),
list: data.list.map(mapper)
};
}
function toStatusActionRequest(data: Api.WorkReport.Common.StatusActionParams = {}) {
return {
reason: data.reason?.trim() || undefined
};
}
function toPersonalReviewItems(items: Api.WorkReport.Common.PersonalReportReviewItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
contentText: item.contentText?.trim() || '',
contentJson: item.contentJson ?? null,
reflectionText: item.reflectionText?.trim() || ''
}));
}
function toPersonalPlanItems(items: Api.WorkReport.Common.PersonalReportPlanItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
targetText: item.targetText?.trim() || '',
targetJson: item.targetJson ?? null,
supportNeed: item.supportNeed?.trim() || ''
}));
}
function toWeeklySaveRequest(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
isBusinessTrip: data.isBusinessTrip,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems),
travelSegments: data.isBusinessTrip
? data.travelSegments.map((item, index) => ({
sort: item.sort ?? index + 1,
startDate: item.startDate || undefined,
endDate: item.endDate || undefined,
travelDays: item.travelDays ?? 0,
location: item.location?.trim() || ''
}))
: []
};
}
function toMonthlySaveRequest(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems)
};
}
function toProjectItems(items: Api.WorkReport.Project.ProjectReportItem[] = []) {
return items.map(item => ({
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
priorityCode: item.priorityCode || undefined,
progressRate: item.progressRate ?? 0
}));
}
function toProjectSaveRequest(data: Api.WorkReport.Project.ProjectReportSaveParams) {
return {
projectId: data.projectId,
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
flag: data.flag,
projectStatusDesc: data.projectStatusDesc?.trim() || '',
projectProgressPlan: data.projectProgressPlan?.trim() || '',
projectKeyPoints: data.projectKeyPoints?.trim() || '',
projectProblems: data.projectProblems?.trim() || '',
currentItems: toProjectItems(data.currentItems),
nextItems: toProjectItems(data.nextItems)
};
}
export async function fetchGetWorkReportStatusDict() {
const result = await request<Api.WorkReport.Common.WorkReportStatusDict[]>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
}
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/page?${query}` : `${WEEKLY_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportApprovalPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/approval-page?${query}` : `${WEEKLY_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportDetail(id: string) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchInitWeeklyReport() {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/init`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchPreviewWeeklyReportDefaultDraft(
params: Api.WorkReport.Weekly.WeeklyReportDefaultDraftParams
) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchCreateWeeklyReport(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: WEEKLY_PREFIX,
method: 'post',
data: toWeeklySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateWeeklyReport(id: string, data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}`,
method: 'put',
data: toWeeklySaveRequest(data)
});
}
export function fetchSubmitWeeklyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteWeeklyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetWeeklyReportApprovalRecords(id: string) {
const result = await request<ApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
data.map(normalizeApprovalRecord)
);
}
export function fetchExportWeeklyReports(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${WEEKLY_PREFIX}/export?${query}` : `${WEEKLY_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportWeeklyReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Weekly.WeeklyReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${WEEKLY_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}
export async function fetchGetMonthlyReportPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/page?${query}` : `${MONTHLY_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
const result = await request<PageResponse<MonthlyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${MONTHLY_PREFIX}/approval-page?${query}` : `${MONTHLY_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
mapPage(data, normalizeMonthlyReport)
);
}
export async function fetchGetMonthlyReportDetail(id: string) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchInitMonthlyReport() {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/init`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchPreviewMonthlyReportDefaultDraft(
params: Api.WorkReport.Monthly.MonthlyReportDefaultDraftParams
) {
const result = await request<MonthlyReportResponse>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
}
export async function fetchCreateMonthlyReport(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: MONTHLY_PREFIX,
method: 'post',
data: toMonthlySaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}`,
method: 'put',
data: toMonthlySaveRequest(data)
});
}
export function fetchSubmitMonthlyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/approve`,
method: 'post',
data
});
}
export function fetchRejectMonthlyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteMonthlyReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetMonthlyReportApprovalRecords(id: string) {
const result = await request<MonthlyApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${MONTHLY_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<MonthlyApprovalRecordResponse[]>, data =>
data.map(normalizeMonthlyApprovalRecord)
);
}
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createMonthlyPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${MONTHLY_PREFIX}/export?${query}` : `${MONTHLY_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportMonthlyReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${MONTHLY_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}
export async function fetchGetProjectReportOwnerProjectOptions() {
const result = await request<ProjectOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/owner-project-options`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProjectOptionResponse[]>, data =>
data.map(normalizeProjectOption)
);
}
export async function fetchGetProjectReportPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
const result = await request<PageResponse<ProjectReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${PROJECT_PREFIX}/page?${query}` : `${PROJECT_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
mapPage(data, normalizeProjectReport)
);
}
export async function fetchGetProjectReportApprovalPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
const result = await request<PageResponse<ProjectReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${PROJECT_PREFIX}/approval-page?${query}` : `${PROJECT_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
mapPage(data, normalizeProjectReport)
);
}
export async function fetchGetProjectReportDetail(id: string) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchInitProjectReport(projectId: string) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/init`,
method: 'get',
params: { projectId }
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchPreviewProjectReportDefaultDraft(
projectId: string,
params: Api.WorkReport.Project.ProjectReportDefaultDraftParams
) {
const result = await request<ProjectReportResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${projectId}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
}
export async function fetchCreateProjectReport(data: Api.WorkReport.Project.ProjectReportSaveParams) {
const result = await request<StringIdResponse>({
...safeJsonRequestConfig,
url: PROJECT_PREFIX,
method: 'post',
data: toProjectSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
}
export function fetchUpdateProjectReport(id: string, data: Api.WorkReport.Project.ProjectReportSaveParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}`,
method: 'put',
data: toProjectSaveRequest(data)
});
}
export function fetchSubmitProjectReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}/submit`, method: 'post' });
}
export function fetchApproveProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteProjectReport(id: string) {
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}`, method: 'delete' });
}
export async function fetchGetProjectReportApprovalRecords(id: string) {
const result = await request<ApprovalRecordResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/approval-records`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
data.map(normalizeApprovalRecord)
);
}
export function fetchExportProjectReports(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createProjectPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${PROJECT_PREFIX}/export?${query}` : `${PROJECT_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}
export function fetchExportProjectReportContent(
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>
) {
return request<Blob, 'blob'>({
url: `${PROJECT_PREFIX}/content-export`,
method: 'post',
data,
responseType: 'blob'
});
}

View File

@@ -0,0 +1,90 @@
import type { InternalAxiosRequestConfig } from 'axios';
declare module 'axios' {
interface AxiosRequestConfig {
dedupe?: boolean;
/**
* 跳过 Authorization 注入。
*
* 用于公开接口refresh-token / login / register 等 PermitAll 路径),
* 避免给它们带上过期 access 头被网关拦截。
*/
skipAuth?: boolean;
/** 请求失败时不走通用错误 toast由调用方自行收敛提示。 */
suppressErrorMessage?: boolean;
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
skipTokenRefresh?: boolean;
}
}
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
dedupe?: boolean;
};
function isFormDataLike(value: unknown): boolean {
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
return false;
}
function stableJson(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
}
export function computeDedupeKey(config: DedupableConfig): string | null {
const method = (config.method ?? 'GET').toUpperCase();
if (!WRITE_METHODS.has(method)) return null;
if (config.dedupe === false) return null;
if (isFormDataLike(config.data)) return null;
const url = config.url ?? '';
const paramsPart = stableJson(config.params);
const bodyPart = stableJson(config.data);
return `${method}|${url}?${paramsPart}|${bodyPart}`;
}
const DEFAULT_TTL_MS = 30_000;
export interface WithDedupeOptions {
ttlMs?: number;
now?: () => number;
}
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
const now = options.now ?? Date.now;
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
return new Proxy(request, {
apply(target, thisArg, args: Parameters<TFn>) {
const [config] = args;
const key = computeDedupeKey(config as DedupableConfig);
if (key === null) return Reflect.apply(target, thisArg, args);
const cached = pending.get(key);
if (cached && cached.expiresAt > now()) return cached.promise;
if (cached) pending.delete(key);
const promise = Promise.resolve()
.then(() => Reflect.apply(target, thisArg, args))
.finally(() => {
const current = pending.get(key);
if (current && current.promise === promise) {
pending.delete(key);
}
});
pending.set(key, { promise, expiresAt: now() + ttl });
return promise;
}
}) as TFn;
}

View File

@@ -0,0 +1,32 @@
export const SESSION_EXPIRED_MESSAGE = '登录已失效,请重新登录';
export interface ErrorMessageSuppressOptions {
backendErrorCode: string;
suppressErrorMessage?: boolean;
logoutCodes: string[];
modalLogoutCodes: string[];
expiredTokenCodes: string[];
}
export interface BackendFailDeferOptions {
suppressErrorMessage?: boolean;
skipTokenRefresh?: boolean;
}
export function parseServiceCodes(codes?: string) {
return codes?.split(',').filter(Boolean) || [];
}
export function shouldDeferBackendFailToCaller(options: BackendFailDeferOptions) {
return Boolean(options.suppressErrorMessage && options.skipTokenRefresh);
}
export function shouldSuppressErrorMessage(options: ErrorMessageSuppressOptions) {
if (options.suppressErrorMessage) {
return true;
}
const handledCodes = [...options.logoutCodes, ...options.modalLogoutCodes, ...options.expiredTokenCodes];
return handledCodes.includes(options.backendErrorCode);
}

View File

@@ -5,15 +5,20 @@ import { localStg } from '@/utils/storage';
import { getServiceBaseURL } from '@/utils/service'; import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { applyApiEncrypt } from './api-encrypt'; import { applyApiEncrypt } from './api-encrypt';
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'; import { parseServiceCodes, shouldDeferBackendFailToCaller, shouldSuppressErrorMessage } from './error-message';
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
import { withDedupe } from './dedupe';
import type { RequestInstanceState } from './type'; import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const REQUEST_TIMEOUT = 15 * 1000;
export const request = createFlatRequest( export const request = withDedupe(
createFlatRequest(
{ {
baseURL, baseURL,
timeout: REQUEST_TIMEOUT,
headers: { headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2' apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
} }
@@ -27,8 +32,12 @@ export const request = createFlatRequest(
return response.data.data; return response.data.data;
}, },
async onRequest(config) { async onRequest(config) {
// skipAuth 为 true 的请求不注入 Authorization——避免给公开接口如 refresh-token
// 带上过期 access 头被网关拦截(网关只看 Authorization不区分路由是否 PermitAll
if (!config.skipAuth) {
const Authorization = getAuthorization(); const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization }); Object.assign(config.headers, { Authorization });
}
applyApiEncrypt(config); applyApiEncrypt(config);
return config; return config;
@@ -42,6 +51,15 @@ export const request = createFlatRequest(
const authStore = useAuthStore(); const authStore = useAuthStore();
const responseCode = String(response.data.code); const responseCode = String(response.data.code);
if (
shouldDeferBackendFailToCaller({
suppressErrorMessage: response.config.suppressErrorMessage,
skipTokenRefresh: response.config.skipTokenRefresh
})
) {
return null;
}
function handleLogout() { function handleLogout() {
authStore.resetStore(); authStore.resetStore();
} }
@@ -53,15 +71,16 @@ export const request = createFlatRequest(
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg); request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
} }
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页 // 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []; // 走 notifySessionExpired 而不是裸 resetStore保证并发请求只弹一次 toast、只清一次状态
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
if (logoutCodes.includes(responseCode)) { if (logoutCodes.includes(responseCode)) {
handleLogout(); notifySessionExpired();
return null; return null;
} }
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录 // 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []; const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) { if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg]; request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
@@ -85,8 +104,13 @@ export const request = createFlatRequest(
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token // 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes` // `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []; const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
if (expiredTokenCodes.includes(responseCode)) { if (expiredTokenCodes.includes(responseCode)) {
if (response.config.skipTokenRefresh) {
notifySessionExpired();
return null;
}
const success = await handleExpiredRequest(request.state); const success = await handleExpiredRequest(request.state);
if (success) { if (success) {
const Authorization = getAuthorization(); const Authorization = getAuthorization();
@@ -104,27 +128,36 @@ export const request = createFlatRequest(
let message = error.message; let message = error.message;
let backendErrorCode = ''; let backendErrorCode = '';
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
message = '请求超时,请稍后重试';
}
// 获取后端错误信息和错误码 // 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) { if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message; message = error.response?.data?.msg || message;
backendErrorCode = String(error.response?.data?.code || ''); backendErrorCode = String(error.response?.data?.code || '');
} }
// 这类错误信息已经通过弹窗展示,不再重复提示 const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []; const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
if (modalLogoutCodes.includes(backendErrorCode)) { const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
return; const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
} if (
shouldSuppressErrorMessage({
// token 过期时会自动刷新并重试请求,这里无需额外提示 backendErrorCode,
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || []; suppressErrorMessage,
if (expiredTokenCodes.includes(backendErrorCode)) { logoutCodes,
modalLogoutCodes,
expiredTokenCodes
})
) {
return; return;
} }
showErrorMsg(request.state, message); showErrorMsg(request.state, message);
} }
} }
)
); );
export const demoRequest = createRequest( export const demoRequest = createRequest(

View File

@@ -1,6 +1,7 @@
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { fetchRefreshToken } from '../api'; import { fetchRefreshToken } from '../api';
import { SESSION_EXPIRED_MESSAGE } from './error-message';
import type { RequestInstanceState } from './type'; import type { RequestInstanceState } from './type';
export function getAuthorization() { export function getAuthorization() {
@@ -12,8 +13,6 @@ export function getAuthorization() {
/** 刷新 token */ /** 刷新 token */
async function handleRefreshToken() { async function handleRefreshToken() {
const { resetStore } = useAuthStore();
const rToken = localStg.get('refreshToken') || ''; const rToken = localStg.get('refreshToken') || '';
const { error, data } = await fetchRefreshToken(rToken); const { error, data } = await fetchRefreshToken(rToken);
if (!error) { if (!error) {
@@ -22,25 +21,48 @@ async function handleRefreshToken() {
return true; return true;
} }
resetStore(); notifySessionExpired();
return false; return false;
} }
export async function handleExpiredRequest(state: RequestInstanceState) { export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) { if (!state.refreshTokenPromise) {
state.refreshTokenFn = handleRefreshToken(); state.refreshTokenPromise = handleRefreshToken();
} }
const success = await state.refreshTokenFn; const success = await state.refreshTokenPromise;
setTimeout(() => { setTimeout(() => {
state.refreshTokenFn = null; state.refreshTokenPromise = null;
}, 1000); }, 1000);
return success; return success;
} }
// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次
let sessionExpiredNotified = false;
/**
* 通知用户会话已失效,弹一次 toast 后清状态、跳登录。
*
* 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。
*/
export function notifySessionExpired() {
if (sessionExpiredNotified) return;
sessionExpiredNotified = true;
window.$message?.error(SESSION_EXPIRED_MESSAGE);
const { resetStore } = useAuthStore();
resetStore();
}
/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */
export function resetSessionExpiredFlag() {
sessionExpiredNotified = false;
}
export function showErrorMsg(state: RequestInstanceState, message: string) { export function showErrorMsg(state: RequestInstanceState, message: string) {
if (!state.errMsgStack?.length) { if (!state.errMsgStack?.length) {
state.errMsgStack = []; state.errMsgStack = [];

View File

@@ -3,5 +3,7 @@ export interface RequestInstanceState {
refreshTokenPromise: Promise<boolean> | null; refreshTokenPromise: Promise<boolean> | null;
/** 请求错误信息栈 */ /** 请求错误信息栈 */
errMsgStack: string[]; errMsgStack: string[];
// 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record<string, unknown>)的硬要求,不能删
// 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useLoading } from '@sa/hooks'; import { useLoading } from '@sa/hooks';
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api'; import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
import { resetSessionExpiredFlag } from '@/service/request/shared';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
@@ -28,6 +29,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const userInfo: Api.Auth.UserInfo = reactive({ const userInfo: Api.Auth.UserInfo = reactive({
userId: '', userId: '',
userName: '', userName: '',
nickname: '',
roles: [], roles: [],
buttons: [] buttons: []
}); });
@@ -49,16 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
clearAuthStorage(); clearAuthStorage();
authStore.$reset(); // setup store 没有内置 $reset需要显式重置内部状态避免 token / userInfo 残留导致 isLogin 误判。
dictStore.resetDictCache(); token.value = '';
objectContextStore.$reset(); Object.assign(userInfo, {
userId: '',
userName: '',
nickname: '',
roles: [],
buttons: []
});
if (!route.meta.constant) { dictStore.resetDictCache();
objectContextStore.clearContext();
// 用路由名判断当前是否已在登录页,避免依赖 route.meta.constant ——
// workbench 等首页也是常量路由,原写法会让常量路由上的登出请求不跳转。
if (route.name !== 'login') {
await toLogin(); await toLogin();
} }
tabStore.cacheTabs(); tabStore.cacheTabs();
routeStore.resetStore(); await routeStore.resetStore();
} }
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */ /** Record the user ID of the previous login session Used to compare with the current user ID on next login */
@@ -118,6 +131,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// If the tab needs to be cleared,it means we don't need to redirect. // If the tab needs to be cleared,it means we don't need to redirect.
needRedirect = false; needRedirect = false;
} }
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
await routeStore.initAuthRoute();
await redirectFromLogin(needRedirect); await redirectFromLogin(needRedirect);
window.$notification?.success({ window.$notification?.success({
@@ -148,6 +167,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
token.value = loginToken.token; token.value = loginToken.token;
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
resetSessionExpiredFlag();
return true; return true;
} }
@@ -167,6 +189,18 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
return false; return false;
} }
async function refreshUserInfo() {
const { data: info, error } = await fetchGetUserInfo(true);
if (!error) {
Object.assign(userInfo, info);
return true;
}
return false;
}
async function initUserInfo() { async function initUserInfo() {
const hasToken = getToken(); const hasToken = getToken();
@@ -189,6 +223,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
loginLoading, loginLoading,
resetStore, resetStore,
login, login,
initUserInfo initUserInfo,
refreshUserInfo
}; };
}); });

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict'; import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
import { fetchGetFrontendDictCache } from '@/service/api'; import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
type DictValue = string | number | null | undefined; type DictValue = string | number | null | undefined;
@@ -19,6 +19,15 @@ function sortDictData(list: Api.Dict.DictData[]) {
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN')); return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
} }
// hex 色值兜底校验:仅接受 #RRGGBB6 位);其他格式(含 #RGB 简写 / rgb())一律视为无效回落到默认渲染
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
function normalizeColorType(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim().toLowerCase();
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
}
function normalizeFrontendDictData( function normalizeFrontendDictData(
dictType: string, dictType: string,
list: Api.Dict.FrontendDictData[], list: Api.Dict.FrontendDictData[],
@@ -31,13 +40,25 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
sort: item.sort, sort: item.sort,
status: item.status ?? 0, status: item.status ?? 0,
remark: null, colorType: normalizeColorType(item.colorType),
remark: item.remark ?? null,
createTime: 0 createTime: 0
})); }));
return sortDictData(normalizedList); return sortDictData(normalizedList);
} }
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
return {
...item,
value: String(item.value),
dictType: item.dictType || dictType,
status: item.status ?? 0,
colorType: normalizeColorType(item.colorType),
remark: item.remark ?? null
};
}
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) { function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
const entries = Object.entries(cache); const entries = Object.entries(cache);
@@ -89,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
const loadedAt = ref<number | null>(null); const loadedAt = ref<number | null>(null);
let initPromise: Promise<boolean> | null = null; let initPromise: Promise<boolean> | null = null;
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
function resetDictCache() { function resetDictCache() {
dictTypes.value = []; dictTypes.value = [];
@@ -96,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
loadedAt.value = null; loadedAt.value = null;
initialized.value = false; initialized.value = false;
initPromise = null; initPromise = null;
dictDataLoadPromises.clear();
} }
async function initDictCache(force = false) { async function initDictCache(force = false) {
@@ -137,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
return initPromise; return initPromise;
} }
async function ensureDictData(dictType: string, force = false) {
if (!dictType) {
return false;
}
if (!initialized.value) {
await initDictCache();
}
if (!force && getDictData(dictType).length > 0) {
return true;
}
const pending = dictDataLoadPromises.get(dictType);
if (pending && !force) {
return pending;
}
const promise = (async () => {
const result = await fetchGetDictDataByCode(dictType);
if (result.error || !result.data?.list?.length) {
return false;
}
dictDataMap.value = {
...dictDataMap.value,
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
};
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
return true;
})();
dictDataLoadPromises.set(dictType, promise);
try {
return await promise;
} finally {
if (dictDataLoadPromises.get(dictType) === promise) {
dictDataLoadPromises.delete(dictType);
}
}
}
function getDictData(dictType: string, onlyEnabled = false) { function getDictData(dictType: string, onlyEnabled = false) {
if (!dictType) { if (!dictType) {
return []; return [];
@@ -199,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
dictDataMap, dictDataMap,
loadedAt, loadedAt,
initDictCache, initDictCache,
ensureDictData,
resetDictCache, resetDictCache,
getDictData, getDictData,
getDictOptions, getDictOptions,

View File

@@ -149,9 +149,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 重置 store */ /** 重置 store */
async function resetStore() { async function resetStore() {
const routeStore = useRouteStore(); // setup store 没有内置 $reset需要显式重置内部状态。
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true导致下面 initConstantRoute 早返,
routeStore.$reset(); // 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
setIsInitConstantRoute(false);
setIsInitAuthRoute(false);
constantRoutes.value = [];
authRoutes.value = [];
menus.value = [];
cacheRoutes.value = [];
excludeCacheRoutes.value = [];
resetVueRoutes(); resetVueRoutes();
@@ -242,7 +249,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 统一处理常量路由和权限路由 */ /** 统一处理常量路由和权限路由 */
async function handleConstantAndAuthRoutes() { async function handleConstantAndAuthRoutes() {
const { getAuthVueRoutes } = await loadRouteModule(); const { getAuthVueRoutes } = await loadRouteModule();
const allRoutes = [...constantRoutes.value, ...authRoutes.value]; // 常量路由优先:动态权限路由中与常量路由 name 重复的项剔除,避免菜单出现重复入口(如 workbench
const constantRouteNames = new Set(constantRoutes.value.map(route => route.name));
const dedupedAuthRoutes = authRoutes.value.filter(route => !constantRouteNames.has(route.name));
const allRoutes = [...constantRoutes.value, ...dedupedAuthRoutes];
const sortRoutes = sortRoutesByOrder(allRoutes); const sortRoutes = sortRoutesByOrder(allRoutes);

View File

@@ -0,0 +1,11 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useWorkbenchLayout } from '@/views/workbench/composables/use-workbench-layout';
import { SetupStoreId } from '@/enum';
import { useAuthStore } from '../auth';
export const useWorkbenchStore = defineStore(SetupStoreId.Workbench, () => {
const authStore = useAuthStore();
const userId = computed(() => String(authStore.userInfo?.userId ?? 'anonymous'));
return useWorkbenchLayout({ userId: userId.value });
});

View File

@@ -406,6 +406,7 @@ html .el-collapse {
.business-table-action-cell { .business-table-action-cell {
display: flex; display: flex;
width: 100%; width: 100%;
box-sizing: border-box;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 0 8px; padding: 0 8px;
@@ -416,6 +417,20 @@ html .el-collapse {
padding: 0 12px; padding: 0 12px;
} }
.business-table-action-icon-button {
min-width: 24px;
height: 24px;
padding: 0;
&.el-button + .el-button {
margin-left: 0;
}
}
.business-table-action-icon {
font-size: 15px;
}
.business-table-action-menu { .business-table-action-menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -428,6 +443,19 @@ html .el-collapse {
margin-left: 0 !important; margin-left: 0 !important;
} }
.business-table-action-menu__link {
width: 100%;
justify-content: flex-start;
margin-left: 0 !important;
padding: 0 4px;
}
.business-table-action-menu__item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.business-table-card-body { .business-table-card-body {
display: flex; display: flex;
height: calc(100% - 56px); height: calc(100% - 56px);

View File

@@ -89,4 +89,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
* *
* If publish new version, use `overrideThemeSettings` to override certain theme settings * If publish new version, use `overrideThemeSettings` to override certain theme settings
*/ */
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {}; // 系统固定亮色主题:切换入口已全部移除,发新版时把老用户缓存的暗色设置刷回亮色
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {
themeScheme: 'light'
};

View File

@@ -13,8 +13,43 @@ declare namespace Api {
interface UserInfo { interface UserInfo {
userId: string; userId: string;
userName: string; userName: string;
nickname: string;
roles: string[]; roles: string[];
buttons: string[]; buttons: string[];
} }
interface MyProfileDetail {
userId: string;
username: string;
nickname?: string | null;
deptId?: string | null;
deptName?: string | null;
positionId?: string | null;
positionName?: string | null;
company?: string | null;
email?: string | null;
mobile?: string | null;
sex?: Api.SystemManage.UserGender | null;
avatar?: string | null;
roles: Api.SystemManage.RoleSimple[];
dept?: Api.SystemManage.DeptSimple | null;
position?: Api.SystemManage.PostSimple | null;
loginIp?: string | null;
loginDate?: string | null;
createTime?: string | null;
}
interface UpdateMyProfileParams {
nickname?: string | null;
email?: string | null;
mobile?: string | null;
sex?: Api.SystemManage.UserGender | null;
avatar?: string | null;
}
interface UpdateMyPasswordParams {
oldPassword: string;
newPassword: string;
}
} }
} }

View File

@@ -55,6 +55,8 @@ declare namespace Api {
sort: number; sort: number;
/** status: 0 enabled, 1 disabled */ /** status: 0 enabled, 1 disabled */
status: DictStatus; status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** create time */ /** create time */
@@ -73,6 +75,10 @@ declare namespace Api {
dictType?: string; dictType?: string;
/** status: 0 enabled, 1 disabled */ /** status: 0 enabled, 1 disabled */
status?: DictStatus; status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** 备注,可用于下拉中文释义展示 */
remark?: string | null;
} }
/** frontend runtime dict cache map */ /** frontend runtime dict cache map */
@@ -82,7 +88,7 @@ declare namespace Api {
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams; type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
/** dict data save params */ /** dict data save params */
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status'> & { type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status' | 'colorType'> & {
remark?: string | null; remark?: string | null;
}; };
} }

101
src/typings/api/infra.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
declare namespace Api {
/**
* namespace Infra
*
* backend api module: "project/status/*"
*/
namespace Infra {
type CommonStatus = 0 | 1;
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
interface ObjectStatusModel {
id: string;
objectType: string;
statusCode: string;
statusName: string;
sort: number;
status: CommonStatus;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
progressExcludedFlag: boolean;
allowCreateProject: boolean;
allowCreateRequirement: boolean;
remark?: string | null;
creator?: string | null;
createTime: string;
updater?: string | null;
updateTime: string;
}
type ObjectStatusModelSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<ObjectStatusModel, 'objectType' | 'status' | 'initialFlag' | 'terminalFlag'> & {
keyword?: string;
}
>;
type SaveObjectStatusModelParams = Pick<
ObjectStatusModel,
| 'objectType'
| 'statusCode'
| 'statusName'
| 'sort'
| 'status'
| 'initialFlag'
| 'terminalFlag'
| 'allowEdit'
| 'progressExcludedFlag'
| 'allowCreateProject'
| 'allowCreateRequirement'
> & {
remark?: string | null;
};
type ObjectStatusModelList = PageResult<ObjectStatusModel>;
interface ObjectStatusTransition {
id: string;
objectType: string;
actionCode: string;
actionName: string;
fromStatusCode: string;
fromStatusName?: string | null;
toStatusCode: string;
toStatusName?: string | null;
needReason: boolean;
status: CommonStatus;
remark?: string | null;
creator?: string | null;
createTime: string;
updater?: string | null;
updateTime: string;
}
type ObjectStatusTransitionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<
ObjectStatusTransition,
'objectType' | 'fromStatusCode' | 'toStatusCode' | 'status' | 'actionCode' | 'actionName'
>
>;
type SaveObjectStatusTransitionParams = Pick<
ObjectStatusTransition,
'objectType' | 'actionCode' | 'actionName' | 'fromStatusCode' | 'toStatusCode' | 'needReason' | 'status'
> & {
remark?: string | null;
};
type ObjectStatusTransitionList = PageResult<ObjectStatusTransition>;
}
}

24
src/typings/api/notice.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare namespace Api {
/**
* namespace Notice
*
* backend api module: "notice"(通知公告)
*/
namespace Notice {
/** 公告ID 在 API 适配层已统一为 string */
interface Notice {
/** 公告编号 */
id: string;
/** 公告标题 */
title: string;
/** 公告类型,字典 system_notice_type */
type: number;
/** 公告内容(富文本 / 纯文本,由录入决定) */
content: string;
/** 状态0 开启 / 1 关闭 */
status: number;
/** 创建时间 */
createTime: string | number;
}
}
}

44
src/typings/api/notify-message.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
declare namespace Api {
/**
* namespace NotifyMessage
*
* backend api module: "notify-message"(站内信 · 我的收件箱)
*/
namespace NotifyMessage {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
/** 站内信(铃铛 / 收件箱展示用ID 在 API 适配层已统一为 string */
interface NotifyMessage {
/** 站内信编号(雪花 Long按 string 接收) */
id: string;
/** 发送人名称(模板配置的发件人显示名) */
templateNickname: string;
/** 最终消息正文(占位符已渲染,直接展示) */
templateContent: string;
/** 消息类型,字典 system_notify_template_type */
templateType: number;
/** 是否已读 */
readStatus: boolean;
/** 阅读时间;未读为 null */
readTime: string | number | null;
/** 收到时间 */
createTime: string | number;
}
/** 我的站内信分页查询参数 */
interface MyPageParams extends PageParams {
/** true 只看已读 / false 只看未读 / 不传 = 全部 */
readStatus?: boolean;
/** 关键字,后端对消息正文模糊匹配;不传或空串 = 不过滤 */
keyword?: string;
}
}
}

View File

@@ -0,0 +1,99 @@
declare namespace Api {
namespace OvertimeApplication {
interface PageParams {
pageNo: number;
pageSize: number;
}
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected';
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject';
interface OvertimeApplication {
id: string;
applicantId: string;
applicantName: string;
overtimeDate: string;
overtimeDuration: string;
overtimeReason: string;
overtimeContent: string;
approverId: string;
approverName: string;
statusCode: OvertimeApplicationStatusCode;
statusName: string;
allowEdit: boolean;
terminal: boolean;
approvalComment?: string | null;
submitTime: string;
approvalTime?: string | null;
createTime: string;
updateTime: string;
}
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
applicantName: string;
approverId: string;
approverName: string;
statusCode: OvertimeApplicationStatusCode;
overtimeDate: string[];
createTime: string[];
}
>;
interface OvertimeApplicationPageResult {
total: number;
list: OvertimeApplication[];
}
interface SaveOvertimeApplicationParams {
overtimeDate: string;
overtimeDuration: string;
overtimeReason: string;
overtimeContent: string;
approverId: string;
}
interface StatusActionParams {
reason?: string | null;
}
interface OvertimeApplicationBatchActionParams {
ids: string[];
reason?: string | null;
}
interface OvertimeApplicationBatchFailItem {
id: string;
reason: string;
}
interface OvertimeApplicationBatchActionResult {
successCount: number;
failCount: number;
failItems: OvertimeApplicationBatchFailItem[];
}
interface OvertimeApplicationApprovalRecord {
id: string;
overtimeApplicationId: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string;
}
interface OvertimeApplicationStatusDict {
statusCode: string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
}
}

99
src/typings/api/personal-item.d.ts vendored Normal file
View File

@@ -0,0 +1,99 @@
declare namespace Api {
namespace PersonalItem {
interface PageParams {
pageNo: number;
pageSize: number;
}
type PersonalItemStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
interface PersonalItemLifecycleAction {
actionCode: string;
actionName: string;
needReason: boolean;
}
interface PersonalItem {
id: string;
taskTitle: string;
type: string;
ownerId: string;
statusCode: PersonalItemStatusCode;
terminal?: boolean;
allowEdit?: boolean;
availableActions?: PersonalItemLifecycleAction[] | null;
progressRate: number;
totalSpentHours?: number | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
actualStartDate: string | null;
actualEndDate: string | null;
taskDesc: string | null;
lastStatusReason: string | null;
attachments: Api.Project.AttachmentItem[] | null;
creator: string;
createTime: string;
updater: string;
updateTime: string;
deleted: boolean;
ownerName?: string | null;
ownerNickname?: string | null;
statusName?: string | null;
}
type PersonalItemSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
ownerId: string;
statusCode: PersonalItemStatusCode;
updateTime: string[];
}
>;
interface PersonalItemPageResult {
total: number;
list: PersonalItem[];
}
interface SavePersonalItemParams {
taskTitle: string;
type: string;
ownerId?: string;
executionId?: string | null;
progressRate?: number | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
attachments: Api.Project.AttachmentItem[] | null;
}
interface UpdatePersonalItemParams extends SavePersonalItemParams {
id: string;
}
interface ChangePersonalItemStatusParams {
actionCode: string;
reason?: string | null;
}
interface PersonalItemExecutionOption {
executionId: string;
executionName: string;
projectId?: string | null;
projectName?: string | null;
}
interface BatchDeletePersonalItemParams {
ids: string[];
}
interface BindPersonalItemExecutionParams {
ids: string[];
executionId: string;
}
type PersonalItemWorklog = Api.Project.TaskWorklog;
type PersonalItemWorklogSearchParams = Api.Project.TaskWorklogSearchParams;
type SavePersonalItemWorklogParams = Api.Project.SaveTaskWorklogParams;
}
}

View File

@@ -21,10 +21,27 @@ declare namespace Api {
list: T[]; list: T[];
} }
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true产品列表暂无"全部"视图,按同构契约返回) */
includeInAll: boolean;
}
/** 产品入口页概览统计 */ /** 产品入口页概览统计 */
interface ProductOverviewSummary { interface ProductOverviewSummary {
/** 产品状态数量映射key 为后端状态编码 */ /** 产品状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和 */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
} }
interface Product { interface Product {
@@ -172,8 +189,10 @@ declare namespace Api {
type ProductSearchParams = CommonType.RecordNullable< type ProductSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & { Pick<Product, 'directionCode' | 'managerUserId'> & {
keyword: string; keyword: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
updateTime: string[]; updateTime: string[];
} }
>; >;
@@ -210,6 +229,32 @@ declare namespace Api {
previousManagerRoleId?: string | null; previousManagerRoleId?: string | null;
} }
/**
* 批量新增产品成员参数
*
* 刻意不复用 CreateProductMemberParams批量接口不承担「产品经理交接」语义
* 后端兜底拒绝 roleId 为产品经理角色的项。
*/
interface BatchCreateProductMembersParams {
members: Array<{
userId: string;
roleId: string;
remark?: string | null;
}>;
}
/**
* 产品创建(含初始团队)原子接口参数
*
* 新增产品两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProductWithTeamParams {
product: SaveProductParams;
members: CreateProductMemberParams[];
/** 关注人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
watcherUserIds?: string[];
}
interface UpdateProductMemberParams { interface UpdateProductMemberParams {
roleId: string; roleId: string;
remark?: string | null; remark?: string | null;
@@ -222,18 +267,37 @@ declare namespace Api {
reason?: string | null; reason?: string | null;
} }
interface BatchInactiveProductMembersParams {
memberIds: string[];
reason?: string | null;
}
// ========== 产品需求相关类型定义 ========== // ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */ /** 需求状态编码 */
type RequirementStatusCode = type RequirementStatusCode =
| 'pending_confirm' | 'pending_claim'
| 'pending_review' | 'pending_review'
| 'pending_dispatch' | 'pending_dispatch'
| 'reviewed'
| 'review_rejected'
| 'implementing' | 'implementing'
| 'accepted' | 'accepted'
| 'closed' | 'closed'
| 'rejected' | 'rejected'
| 'cancelled'; | 'cancelled';
/** 需求状态动作编码 */
type RequirementStatusActionCode =
| 'claim_to_review'
| 'claim_to_dispatch'
| 'pass_review'
| 'reject_review'
| 'dispatch'
| 'cancel'
| 'accept'
| 'close'
| 'reject';
/** 需求来源类型 */ /** 需求来源类型 */
type RequirementSourceType = 'manual' | 'work_order'; type RequirementSourceType = 'manual' | 'work_order';
@@ -256,17 +320,19 @@ declare namespace Api {
moduleId: string; moduleId: string;
/** 是否需要评审0不需要1需要 */ /** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired; reviewRequired: RequirementReviewRequired;
/** 需求标题 */ /** 需求名称 */
title: string; title: string;
/** 需求描述(富文本) */ /** 需求内容(富文本) */
description?: string | null; description?: string | null;
/** 需求分类字典值 */ /** 附件列表 */
attachments?: Api.Project.AttachmentItem[] | null;
/** 需求类型字典值 */
category: string; category: string;
/** 需求类名称 */ /** 需求类名称 */
categoryName?: string | null; categoryName?: string | null;
/** 来源类型 */ /** 需求来源类型 */
sourceType: RequirementSourceType; sourceType: RequirementSourceType;
/** 来源业务ID */ /** 需求来源业务ID */
sourceBizId?: string | null; sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */ /** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority; priority: RequirementPriority;
@@ -286,12 +352,12 @@ declare namespace Api {
currentHandlerUserId?: string | null; currentHandlerUserId?: string | null;
/** 当前处理人姓名 */ /** 当前处理人姓名 */
currentHandlerUserNickname?: string | null; currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */ /** 默认关联项目编号 */
implementProjectId?: string | null; implementProjectId?: string | null;
/** 实现项目名称 */ /** 默认关联项目名称 */
implementProjectName?: string | null; implementProjectName?: string | null;
/** 预期完成时间 */ /** 预期完成日期 */
completionDate: string; expectedTime?: string | null;
/** 排序值 */ /** 排序值 */
sort: number; sort: number;
/** 创建时间 */ /** 创建时间 */
@@ -300,8 +366,6 @@ declare namespace Api {
updateTime: string; updateTime: string;
/** 子需求列表(树形结构) */ /** 子需求列表(树形结构) */
children?: Requirement[]; children?: Requirement[];
/** 是否为终态 */
terminal?: boolean;
} }
// ========== 需求模块实体 ========== // ========== 需求模块实体 ==========
@@ -338,25 +402,103 @@ declare namespace Api {
initialFlag: boolean; initialFlag: boolean;
/** 是否终态 */ /** 是否终态 */
terminalFlag: boolean; terminalFlag: boolean;
/** 是否允许编辑 */
allowEdit: boolean;
} }
// ========== 需求生命周期 ==========
interface RequirementLifecycleAction { interface RequirementLifecycleAction {
actionCode: string; actionCode: RequirementStatusActionCode;
actionName: string; actionName: string;
toStatusCode: string; toStatusCode: string;
toStatusName: string; toStatusName: string;
needReason: boolean; needReason: boolean;
} }
interface RequirementLifecycleInfo { interface RequirementBatchReqVO {
statusCode: RequirementStatusCode; productId: string;
statusName?: string | null; requirementIds: string[];
lastStatusReason?: string | null; }
terminal: boolean;
allowEdit: boolean; interface RequirementAllowedTransitionBatchRespVO {
availableActions: RequirementLifecycleAction[]; requirementId: string;
transitions: RequirementLifecycleAction[];
}
interface RequirementHasDispatchedBatchRespVO {
requirementId: string;
hasDispatched: boolean;
}
type ProductRequirementDashboardRecentChangeActionType = 'create' | 'delete' | 'status_terminal';
interface ProductRequirementDashboardSummary {
/** 当前产品下所有未删除需求数,包括根需求和子需求 */
total: number;
/** 待认领、待评审、待指派的需求数 */
todo: number;
/** 待认领需求数 */
pendingClaim: number;
/** 待评审需求数 */
pendingReview: number;
/** 待指派需求数 */
pendingDispatch: number;
/** 已验收或已关闭需求数 */
completed: number;
/** 完成率0-100 */
completionRate: number;
/** P0/P1 且待处理的需求数 */
highPriorityTodo: number;
}
interface ProductRequirementDashboardRecentChange {
id: string;
requirementId?: string | null;
title: string;
actionType: ProductRequirementDashboardRecentChangeActionType;
actionLabel: string;
content: string;
occurredAt: string;
operatorUserId?: string | null;
operatorName?: string | null;
}
interface ProductRequirementDashboard {
summary: ProductRequirementDashboardSummary;
recentChanges: ProductRequirementDashboardRecentChange[];
}
type RequirementReviewConclusion = 0 | 1;
interface RequirementReviewAttendeeItem {
userId: string;
nickname: string;
}
interface RequirementReview {
id: string;
objectType: 'product_requirement';
requirementId: string;
operatorId: string;
conclusion: RequirementReviewConclusion;
reviewContent?: string | null;
requirementEstimatedHours?: number | string | null;
attendees?: RequirementReviewAttendeeItem[];
attachments?: Api.Project.AttachmentItem[] | null;
reviewTime?: string | null;
createTime?: string;
updateTime?: string;
}
interface RequirementReviewSubmitParams {
productId: string;
requirementId: string;
operatorId: string;
conclusion: RequirementReviewConclusion;
reviewContent?: string | null;
requirementEstimatedHours?: number | string | null;
attendees?: RequirementReviewAttendeeItem[];
attachments?: Api.Project.AttachmentItem[] | null;
reviewTime?: string | null;
} }
// ========== 请求参数类型 ========== // ========== 请求参数类型 ==========
@@ -381,12 +523,15 @@ declare namespace Api {
| 'reviewRequired' | 'reviewRequired'
| 'title' | 'title'
| 'description' | 'description'
| 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'proposerId' | 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'implementProjectId' | 'implementProjectId'
| 'completionDate' | 'expectedTime'
| 'sort' | 'sort'
>; >;
@@ -418,11 +563,14 @@ declare namespace Api {
| 'reviewRequired' | 'reviewRequired'
| 'title' | 'title'
| 'description' | 'description'
| 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'proposerId' | 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'completionDate' | 'currentHandlerUserNickname'
| 'expectedTime'
| 'sort' | 'sort'
>; >;

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,8 @@ declare namespace Api {
type: RoleType; type: RoleType;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** 是否在前端选择面板可见0 不可见 / 1 可见,缺省视作可见 */
visible?: 0 | 1 | null;
/** create time */ /** create time */
createTime: number; createTime: number;
} }
@@ -69,7 +71,7 @@ declare namespace Api {
roleCode: string; roleCode: string;
}; };
type DeptOrgType = 'company' | 'dept' | 'direction' | 'team'; type DeptOrgType = 'company' | 'dept' | 'function' | 'direction' | 'team';
interface Dept { interface Dept {
id: number; id: number;
@@ -148,6 +150,7 @@ declare namespace Api {
sex?: UserGender | null; sex?: UserGender | null;
avatar?: string | null; avatar?: string | null;
status: CommonStatus; status: CommonStatus;
sort?: number;
loginIp?: string | null; loginIp?: string | null;
resignedAt?: number | null; resignedAt?: number | null;
loginDate?: number | null; loginDate?: number | null;
@@ -178,6 +181,7 @@ declare namespace Api {
mobile?: string | null; mobile?: string | null;
sex?: UserGender | null; sex?: UserGender | null;
avatar?: string | null; avatar?: string | null;
sort?: number;
password?: string; password?: string;
}; };
@@ -224,7 +228,7 @@ declare namespace Api {
type PostList = PageResult<Post>; type PostList = PageResult<Post>;
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort'>; type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort' | 'remark' | 'visible'>;
type RoleSimpleList = RoleSimple[]; type RoleSimpleList = RoleSimple[];

290
src/typings/api/work-report.d.ts vendored Normal file
View File

@@ -0,0 +1,290 @@
declare namespace Api {
namespace WorkReport {
namespace Common {
interface PageParams {
pageNo: number;
pageSize: number;
}
type ReportType = 'weekly' | 'monthly' | 'project';
type WorkReportStatusCode = 'draft' | 'pending_approval' | 'approved' | 'rejected';
interface WorkReportStatusDict {
statusCode: WorkReportStatusCode | string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
interface WorkReportApprovalRecord {
id: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string;
}
interface PersonalReportReviewItem {
id?: string;
itemNumber?: number | null;
itemTitle: string;
workHours?: number | null;
contentText?: string | null;
contentJson?: unknown;
reflectionText?: string | null;
}
interface PersonalReportPlanItem {
id?: string;
itemNumber?: number | null;
itemTitle: string;
targetText?: string | null;
targetJson?: unknown;
supportNeed?: string | null;
}
type WorkReportBaseSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
statusCode: WorkReportStatusCode | string;
periodStartDate: string[];
submitTime: string[];
supervisorName: string;
}
>;
type ContentExportParams<TSearch> = Partial<TSearch> & {
exportAll?: boolean;
ids?: string[];
};
interface StatusActionParams {
reason?: string | null;
}
interface PageResult<T> {
total: number;
list: T[];
}
}
namespace Weekly {
interface WeeklyReportTravelSegment {
id?: string;
sort?: number | null;
startDate?: string | null;
endDate?: string | null;
travelDays?: number | null;
location?: string | null;
}
interface WeeklyReport {
id: string;
reporterId: string;
reporterName: string;
reporterDeptName?: string | null;
reporterPostName?: string | null;
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
isBusinessTrip: boolean;
totalTravelDays?: number | string | null;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
travelSegments: WeeklyReportTravelSegment[];
}
type WeeklyReportSearchParams = Common.WorkReportBaseSearchParams & {
isBusinessTrip?: boolean | string | null;
};
interface WeeklyReportSaveParams {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
isBusinessTrip: boolean;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
travelSegments: WeeklyReportTravelSegment[];
}
type WeeklyReportDefaultDraftParams = Pick<
WeeklyReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
>;
}
namespace Monthly {
interface MonthlyReport {
id: string;
reporterId: string;
reporterName: string;
reporterDeptName?: string | null;
reporterPostName?: string | null;
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams;
interface MonthlyReportSaveParams {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
reviewItems: Common.PersonalReportReviewItem[];
planItems: Common.PersonalReportPlanItem[];
}
type MonthlyReportDefaultDraftParams = Pick<
MonthlyReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
>;
interface MonthlyReportApproveParams extends Common.StatusActionParams {
meetingDate?: string | null;
strengthDesc?: string | null;
strengthExample?: string | null;
weaknessDesc?: string | null;
weaknessExample?: string | null;
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignedDate?: string | null;
}
interface MonthlyReportApprovalRecord extends Common.WorkReportApprovalRecord {
meetingDate?: string | null;
strengthDesc?: string | null;
strengthExample?: string | null;
weaknessDesc?: string | null;
weaknessExample?: string | null;
improvementSuggestion?: string | null;
performanceResult?: string | null;
employeeSignName?: string | null;
employeeSignedDate?: string | null;
supervisorSignName?: string | null;
supervisorSignedDate?: string | null;
}
}
namespace Project {
interface WorkReportMemberSnapshot {
userId: string;
userName: string;
}
interface ProjectReportItem {
id?: string;
itemTitle: string;
workHours?: number | null;
priorityCode?: string | null;
progressRate?: number | null;
}
interface ProjectReportOwnerProjectOption {
id: string;
projectCode: string;
projectName: string;
}
interface ProjectReport {
id: string;
projectId: string;
projectName: string;
projectOwnerId: string;
projectOwnerName: string;
technicalOwnerName?: string | null;
projectMemberSnapshot: WorkReportMemberSnapshot[];
supervisorUserId: string;
supervisorName: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
flag: number;
statusCode: Common.WorkReportStatusCode | string;
statusName: string;
allowEdit: boolean;
terminal: boolean;
projectStatusDesc?: string | null;
projectProgressPlan?: string | null;
projectKeyPoints?: string | null;
projectProblems?: string | null;
totalWorkHours?: number | string | null;
approvalComment?: string | null;
lastStatusReason?: string | null;
submitTime?: string | null;
approvalTime?: string | null;
createTime?: string | null;
updateTime?: string | null;
currentItems: ProjectReportItem[];
nextItems: ProjectReportItem[];
}
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
projectId?: string | null;
flag?: number | null;
};
interface ProjectReportSaveParams {
projectId: string;
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
flag: number;
projectStatusDesc?: string | null;
projectProgressPlan?: string | null;
projectKeyPoints?: string | null;
projectProblems?: string | null;
currentItems: ProjectReportItem[];
nextItems: ProjectReportItem[];
}
type ProjectReportDefaultDraftParams = Pick<
ProjectReportSaveParams,
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate' | 'flag'
>;
}
}
}

44
src/typings/app.d.ts vendored
View File

@@ -333,7 +333,7 @@ declare namespace App {
trigger: string; trigger: string;
update: string; update: string;
updateSuccess: string; updateSuccess: string;
userCenter: string; myProfile: string;
yesOrNo: { yesOrNo: {
yes: string; yes: string;
no: string; no: string;
@@ -504,45 +504,6 @@ declare namespace App {
}; };
creativity: string; creativity: string;
}; };
function: {
tab: {
tabOperate: {
title: string;
addTab: string;
addTabDesc: string;
closeTab: string;
closeCurrentTab: string;
closeAboutTab: string;
addMultiTab: string;
addMultiTabDesc1: string;
addMultiTabDesc2: string;
};
tabTitle: {
title: string;
changeTitle: string;
change: string;
resetTitle: string;
reset: string;
};
};
multiTab: {
routeParam: string;
backTab: string;
};
toggleAuth: {
toggleAccount: string;
authHook: string;
superAdminVisible: string;
adminVisible: string;
adminOrUserVisible: string;
};
request: {
repeatedErrorOccurOnce: string;
repeatedError: string;
repeatedErrorMsg1: string;
repeatedErrorMsg2: string;
};
};
system: { system: {
common: { common: {
status: { status: {
@@ -684,6 +645,7 @@ declare namespace App {
orgType: { orgType: {
company: string; company: string;
dept: string; dept: string;
function: string;
direction: string; direction: string;
team: string; team: string;
}; };
@@ -865,6 +827,7 @@ declare namespace App {
dictStatus: string; dictStatus: string;
dictLabel: string; dictLabel: string;
dictValue: string; dictValue: string;
colorType: string;
sort: string; sort: string;
remark: string; remark: string;
form: { form: {
@@ -873,6 +836,7 @@ declare namespace App {
dictStatus: string; dictStatus: string;
dictLabel: string; dictLabel: string;
dictValue: string; dictValue: string;
colorType: string;
sort: string; sort: string;
remark: string; remark: string;
}; };

View File

@@ -9,7 +9,9 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppProvider: typeof import('./../components/common/app-provider.vue')['default'] AppProvider: typeof import('./../components/common/app-provider.vue')['default']
AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default'] BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default'] BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default'] BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default'] BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
@@ -17,6 +19,7 @@ declare module 'vue' {
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default'] BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default'] BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default'] BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default'] BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default'] ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default'] CountTo: typeof import('./../components/custom/count-to.vue')['default']
@@ -54,8 +57,10 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
@@ -97,10 +102,19 @@ declare module 'vue' {
IconCarbonStop: typeof import('~icons/carbon/stop')['default'] IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['default'] 'IconCharm:download': typeof import('~icons/charm/download')['default']
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default'] 'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
'IconEp:box': typeof import('~icons/ep/box')['default']
'IconEp:check': typeof import('~icons/ep/check')['default']
'IconEp:files': typeof import('~icons/ep/files')['default']
'IconEp:folder': typeof import('~icons/ep/folder')['default']
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
'IconEp:plus': typeof import('~icons/ep/plus')['default']
'IconEp:sort': typeof import('~icons/ep/sort')['default']
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default'] IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default'] IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default'] 'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default'] 'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
'IconFe:eye': typeof import('~icons/fe/eye')['default']
'IconFe:question': typeof import('~icons/fe/question')['default'] 'IconFe:question': typeof import('~icons/fe/question')['default']
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default'] 'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
'IconGg:ratio': typeof import('~icons/gg/ratio')['default'] 'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
@@ -108,12 +122,15 @@ declare module 'vue' {
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default'] 'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default'] 'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default'] IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default'] IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default'] IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default'] IconLocalActivity: typeof import('~icons/local/activity')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalCast: typeof import('~icons/local/cast')['default'] IconLocalCast: typeof import('~icons/local/cast')['default']
@@ -130,9 +147,12 @@ declare module 'vue' {
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default'] IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default'] IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default'] IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default'] IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default'] IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
@@ -140,6 +160,7 @@ declare module 'vue' {
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default'] IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default'] IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default'] IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default'] IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default'] IconMdiPlus: typeof import('~icons/mdi/plus')['default']
@@ -164,6 +185,7 @@ declare module 'vue' {
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default'] TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default'] TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default'] ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default'] WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default'] WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
} }

View File

@@ -24,42 +24,26 @@ declare module "@elegant-router/types" {
"403": "/403"; "403": "/403";
"404": "/404"; "404": "/404";
"500": "/500"; "500": "/500";
"function": "/function";
"function_hide-child": "/function/hide-child";
"function_hide-child_one": "/function/hide-child/one";
"function_hide-child_three": "/function/hide-child/three";
"function_hide-child_two": "/function/hide-child/two";
"function_multi-tab": "/function/multi-tab";
"function_request": "/function/request";
"function_super-page": "/function/super-page";
"function_tab": "/function/tab";
"function_toggle-auth": "/function/toggle-auth";
"iframe-page": "/iframe-page/:url"; "iframe-page": "/iframe-page/:url";
"infra": "/infra";
"infra_rd-code": "/infra/rd-code";
"infra_state-machine": "/infra/state-machine";
"login": "/login/:module(pwd-login|reset-pwd)?"; "login": "/login/:module(pwd-login|reset-pwd)?";
"plugin": "/plugin"; "metrics": "/metrics";
"plugin_barcode": "/plugin/barcode"; "metrics_member-efficiency": "/metrics/member-efficiency";
"plugin_charts": "/plugin/charts"; "metrics_project-progress": "/metrics/project-progress";
"plugin_charts_antv": "/plugin/charts/antv"; "metrics_worktime": "/metrics/worktime";
"plugin_charts_echarts": "/plugin/charts/echarts"; "personal-center": "/personal-center";
"plugin_charts_vchart": "/plugin/charts/vchart"; "personal-center_my-application": "/personal-center/my-application";
"plugin_copy": "/plugin/copy"; "personal-center_my-item": "/personal-center/my-item";
"plugin_editor": "/plugin/editor"; "personal-center_my-performance": "/personal-center/my-performance";
"plugin_editor_markdown": "/plugin/editor/markdown"; "personal-center_my-profile": "/personal-center/my-profile";
"plugin_editor_quill": "/plugin/editor/quill"; "personal-center_overtime-application": "/personal-center/overtime-application";
"plugin_excel": "/plugin/excel"; "personal-center_pending-approval": "/personal-center/pending-approval";
"plugin_gantt": "/plugin/gantt"; "personal-center_work-report": "/personal-center/work-report";
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx"; "personal-center_work-report_monthly": "/personal-center/work-report/monthly";
"plugin_gantt_vtable": "/plugin/gantt/vtable"; "personal-center_work-report_project": "/personal-center/work-report/project";
"plugin_icon": "/plugin/icon"; "personal-center_work-report_weekly": "/personal-center/work-report/weekly";
"plugin_map": "/plugin/map";
"plugin_pdf": "/plugin/pdf";
"plugin_pinyin": "/plugin/pinyin";
"plugin_print": "/plugin/print";
"plugin_swiper": "/plugin/swiper";
"plugin_tables": "/plugin/tables";
"plugin_tables_vtable": "/plugin/tables/vtable";
"plugin_typeit": "/plugin/typeit";
"plugin_video": "/plugin/video";
"product": "/product"; "product": "/product";
"product_dashboard": "/product/dashboard"; "product_dashboard": "/product/dashboard";
"product_list": "/product/list"; "product_list": "/product/list";
@@ -80,7 +64,10 @@ declare module "@elegant-router/types" {
"system_user": "/system/user"; "system_user": "/system/user";
"system_user-detail": "/system/user-detail/:id"; "system_user-detail": "/system/user-detail/:id";
"system_user-management-relation": "/system/user-management-relation"; "system_user-management-relation": "/system/user-management-relation";
"user-center": "/user-center"; "ticket": "/ticket";
"ticket_my-pending": "/ticket/my-pending";
"ticket_my-submitted": "/ticket/my-submitted";
"workbench": "/workbench";
}; };
/** /**
@@ -119,14 +106,16 @@ declare module "@elegant-router/types" {
| "403" | "403"
| "404" | "404"
| "500" | "500"
| "function"
| "iframe-page" | "iframe-page"
| "infra"
| "login" | "login"
| "plugin" | "metrics"
| "personal-center"
| "product" | "product"
| "project" | "project"
| "system" | "system"
| "user-center" | "ticket"
| "workbench"
>; >;
/** /**
@@ -149,33 +138,21 @@ declare module "@elegant-router/types" {
| "500" | "500"
| "iframe-page" | "iframe-page"
| "login" | "login"
| "function_hide-child_one" | "infra_rd-code"
| "function_hide-child_three" | "infra_state-machine"
| "function_hide-child_two" | "metrics_member-efficiency"
| "function_multi-tab" | "metrics_project-progress"
| "function_request" | "metrics_worktime"
| "function_super-page" | "personal-center_my-application"
| "function_tab" | "personal-center_my-item"
| "function_toggle-auth" | "personal-center_my-performance"
| "plugin_barcode" | "personal-center_my-profile"
| "plugin_charts_antv" | "personal-center_overtime-application"
| "plugin_charts_echarts" | "personal-center_pending-approval"
| "plugin_charts_vchart" | "personal-center_work-report"
| "plugin_copy" | "personal-center_work-report_monthly"
| "plugin_editor_markdown" | "personal-center_work-report_project"
| "plugin_editor_quill" | "personal-center_work-report_weekly"
| "plugin_excel"
| "plugin_gantt_dhtmlx"
| "plugin_gantt_vtable"
| "plugin_icon"
| "plugin_map"
| "plugin_pdf"
| "plugin_pinyin"
| "plugin_print"
| "plugin_swiper"
| "plugin_tables_vtable"
| "plugin_typeit"
| "plugin_video"
| "product_dashboard" | "product_dashboard"
| "product_list" | "product_list"
| "product_requirement" | "product_requirement"
@@ -192,7 +169,9 @@ declare module "@elegant-router/types" {
| "system_user-detail" | "system_user-detail"
| "system_user-management-relation" | "system_user-management-relation"
| "system_user" | "system_user"
| "user-center" | "ticket_my-pending"
| "ticket_my-submitted"
| "workbench"
>; >;
/** /**

View File

@@ -1,20 +0,0 @@
/// <reference types="@amap/amap-jsapi-types" />
/// <reference types="bmapgl" />
declare namespace BMap {
class Map extends BMapGL.Map {}
class Point extends BMapGL.Point {}
}
declare const TMap: any;
interface Window {
/**
* make baidu map request under https protocol
*
* - 0: http
* - 1: https
* - 2: https
*/
HOST_TYPE: '0' | '1' | '2';
}

20
src/utils/datetime.ts Normal file
View File

@@ -0,0 +1,20 @@
import dayjs from 'dayjs';
/** 相对时间展示:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 7 天回退完整日期 */
export function formatRelativeTime(value: string | number) {
const time = dayjs(value);
if (!time.isValid()) return '';
const now = dayjs();
const diffMinutes = now.diff(time, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = now.diff(time, 'hour');
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = now.diff(time, 'day');
if (diffDays < 7) return `${diffDays} 天前`;
return time.format('YYYY-MM-DD HH:mm');
}

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, reactive } from 'vue';
import type { Component } from 'vue'; import type { CSSProperties, Component } from 'vue';
import { getPaletteColorByNumber, mixColor } from '@sa/color';
import { loginModuleRecord } from '@/constants/app'; import { loginModuleRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales'; import { $t } from '@/locales';
@@ -31,46 +30,791 @@ const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']); const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
const bgThemeColor = computed(() => const currentYear = new Date().getFullYear();
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
);
const bgColor = computed(() => { /** 登录页品牌色:取自公司 logo 的湛蓝,不跟随系统主题色(主题色偏紫,与企业蓝不符) */
const COLOR_WHITE = '#ffffff'; const LOGIN_BRAND = '#1e80df';
const ratio = themeStore.darkMode ? 0.5 : 0.2; /** 鼠标视差:归一化指针位置,不同景深的层按系数反向位移 */
const pointer = reactive({ x: 0, y: 0 });
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio); function onPointerMove(event: MouseEvent) {
}); pointer.x = (event.clientX / window.innerWidth - 0.5) * 2;
pointer.y = (event.clientY / window.innerHeight - 0.5) * 2;
}
function layerStyle(depth: number) {
return {
transform: `translate3d(${(-pointer.x * depth).toFixed(1)}px, ${(-pointer.y * depth).toFixed(1)}px, 0)`
};
}
/** 协作分支:角色 → 颜色 → 汇入主干的路径git 分支汇流的意象),曲线两端均水平相切,过渡柔和 */
const branches = [
{
key: 'demand',
label: '需求',
color: '#f59e0b',
y0: 195,
mergeX: 660,
path: 'M -80,195 C 320,195 440,470 660,470',
dur: '7.5s',
begin: '0s'
},
{
key: 'design',
label: '设计',
color: '#ec4899',
y0: 330,
mergeX: 780,
path: 'M -80,330 C 360,330 540,470 780,470',
dur: '6.5s',
begin: '1.2s'
},
{
key: 'dev',
label: '开发',
color: '#0ea5e9',
y0: 615,
mergeX: 880,
path: 'M -80,615 C 380,615 560,470 880,470',
dur: '7s',
begin: '2.1s'
},
{
key: 'test',
label: '测试',
color: '#22c55e',
y0: 745,
mergeX: 970,
path: 'M -80,745 C 420,745 620,470 970,470',
dur: '8s',
begin: '0.6s'
}
];
/** 分支汇入主干的节点位置 */
const mergePoints = [
{ x: 660, color: '#f59e0b' },
{ x: 780, color: '#ec4899' },
{ x: 880, color: '#0ea5e9' },
{ x: 970, color: '#22c55e' }
];
/** 角色徽章在场景中的落位(跟随分支起始段) */
const roleChips: { label: string; color: string; style: CSSProperties }[] = [
{ label: '需求', color: '#f59e0b', style: { left: '5%', top: '20%', '--float-d': '0s' } },
{ label: '设计', color: '#ec4899', style: { left: '11%', top: '35%', '--float-d': '0.8s' } },
{ label: '开发', color: '#0ea5e9', style: { left: '8%', top: '66%', '--float-d': '1.6s' } },
{ label: '测试', color: '#22c55e', style: { left: '13%', top: '80%', '--float-d': '2.4s' } }
];
/**
* 电能质量波形(公司主营:电能质量监测)
*
* 主干汇流完成后,尾段"输出"为基波 + 谐波叠加的正弦波组,寓意协作成果守护电能质量。
* 用二次贝塞尔 Q/T 拼接出周期波形CSS 平移一个整周期实现无缝流动。
*/
interface WaveShape {
/** 波形中线 y */
mid: number;
/** 振幅 */
amp: number;
/** 半周期x 方向) */
half: number;
}
function buildWavePath(shape: WaveShape, from: number, to: number) {
const { mid, amp, half } = shape;
let d = `M ${from} ${mid} Q ${from + half / 2} ${mid - amp} ${from + half} ${mid}`;
for (let x = from + 2 * half; x <= to; x += half) {
d += ` T ${x} ${mid}`;
}
return d;
}
const waves = [
// 基波:主题色,振幅最大
{ key: 'fundamental', mid: 470, amp: 26, half: 110, color: 'var(--brand)', width: 2, opacity: 0.5, dur: '7s' },
// 高次谐波:短周期小振幅
{ key: 'harmonic', mid: 470, amp: 10, half: 55, color: '#0ea5e9', width: 1.5, opacity: 0.45, dur: '4.5s' },
// 低频包络:慢速衬底
{ key: 'flux', mid: 474, amp: 40, half: 220, color: '#60a5fa', width: 2, opacity: 0.22, dur: '14s' }
].map(wave => ({
...wave,
path: buildWavePath(wave, 900 - wave.half * 2, 2000 + wave.half * 2),
shift: `${-2 * wave.half}px`
}));
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
const towers = [
{ x: 150, s: 1 },
{ x: 540, s: 0.85 },
{ x: 1280, s: 0.7 }
];
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */
const powerLines = [
{ path: 'M -60,762 Q 30,800 92,750' },
{ path: 'M 208,750 Q 350,819 491,768' },
{ path: 'M 589,768 Q 914,867 1239,786' },
{ path: 'M 1321,786 Q 1430,824 1520,804' }
];
/** 导线上滑过的电流光点 */
const lineSparks = [
{ key: 'spark-1', path: 'M 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' },
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' }
];
/** 风机新能源应用场景dur 为叶轮旋转周期 */
const turbines = [
{ key: 'turbine-1', x: 715, s: 0.9, dur: '9s' },
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' }
];
</script> </script>
<template> <template>
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }"> <div class="login-scene" :style="{ '--brand': LOGIN_BRAND }" @mousemove="onPointerMove">
<WaveBg :theme-color="bgThemeColor" /> <!-- 远景浮尘微粒 -->
<ElCard class="relative z-4 w-auto rd-12px"> <div class="scene-motes" :style="layerStyle(6)"></div>
<div class="w-400px lt-sm:w-300px">
<header class="flex-y-center justify-between"> <!-- 中景协作汇流图需求/设计/开发/测试 主干 登录入口 -->
<SystemLogo class="text-64px text-primary lt-sm:text-48px" /> <div class="scene-graph" :style="layerStyle(14)">
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3> <svg class="scene-graph__svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<div class="i-flex-col"> <defs>
<ThemeSchemaSwitch <linearGradient id="trunk-grad" x1="0" y1="0" x2="1" y2="0">
:theme-schema="themeStore.themeScheme" <!-- presentation attribute 不解析 CSS var必须用 style -->
:show-tooltip="false" <stop offset="0" style="stop-color: var(--brand); stop-opacity: 0" />
class="text-20px lt-sm:text-18px" <stop offset="0.45" style="stop-color: var(--brand); stop-opacity: 0.85" />
@switch="themeStore.toggleThemeScheme" <stop offset="1" style="stop-color: #0ea5e9; stop-opacity: 0.9" />
</linearGradient>
<!-- 每条分支一个渐变起点透明临近汇入处渐显模拟光流自然汇聚 -->
<linearGradient
v-for="branch in branches"
:id="`branch-grad-${branch.key}`"
:key="`grad-${branch.key}`"
gradientUnits="userSpaceOnUse"
:x1="-80"
:y1="branch.y0"
:x2="branch.mergeX"
:y2="470"
>
<stop offset="0" :stop-color="branch.color" stop-opacity="0" />
<stop offset="0.45" :stop-color="branch.color" stop-opacity="0.2" />
<stop offset="1" :stop-color="branch.color" stop-opacity="0.65" />
</linearGradient>
<filter id="trunk-glow" x="-30%" y="-300%" width="160%" height="700%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 波形渐显遮罩汇流完成后波形才"长出来"遮罩静止波形在其下平移 -->
<linearGradient id="wave-fade" gradientUnits="userSpaceOnUse" x1="920" y1="0" x2="1980" y2="0">
<stop offset="0" stop-color="#fff" stop-opacity="0" />
<stop offset="0.3" stop-color="#fff" stop-opacity="0.9" />
<stop offset="1" stop-color="#fff" stop-opacity="1" />
</linearGradient>
<mask id="wave-mask">
<rect x="920" y="330" width="1100" height="300" fill="url(#wave-fade)" />
</mask>
</defs>
<!-- 主干 -->
<path class="trunk" d="M -60,470 L 1520,470" stroke="url(#trunk-grad)" filter="url(#trunk-glow)" />
<!-- 四条角色分支 -->
<path
v-for="(branch, index) in branches"
:key="branch.key"
class="branch"
:d="branch.path"
:stroke="`url(#branch-grad-${branch.key})`"
:style="{ '--breathe-d': `${index * 1.4}s` }"
/> />
<!-- 分支上行进的光点 -->
<circle
v-for="branch in branches"
:key="`dot-${branch.key}`"
r="3.5"
:fill="branch.color"
class="travel-dot"
:style="{ color: branch.color }"
>
<animateMotion :dur="branch.dur" :begin="branch.begin" repeatCount="indefinite" :path="branch.path" />
</circle>
<!-- 主干上行进的光点 -->
<circle r="3" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
<animateMotion dur="4.5s" begin="0.5s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
</circle>
<circle r="2.5" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
<animateMotion dur="5.5s" begin="2.6s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
</circle>
<!-- 电能质量波形基波 + 谐波 + 低频包络沿主干尾段流出 -->
<g mask="url(#wave-mask)">
<path
v-for="wave in waves"
:key="wave.key"
class="wave"
:d="wave.path"
:stroke-width="wave.width"
:style="{
stroke: wave.color,
opacity: wave.opacity,
'--wave-shift': wave.shift,
'--wave-dur': wave.dur
}"
/>
</g>
<!-- 电力场景剪影输电铁塔 + 悬垂导线 + 风机 -->
<g class="industry">
<!-- 输电铁塔 -->
<g
v-for="tower in towers"
:key="`tower-${tower.x}`"
:transform="`translate(${tower.x}, 870) scale(${tower.s})`"
>
<path d="M -32 0 L -10 -150 L 0 -178 L 10 -150 L 32 0" />
<path
d="M -28 -25 L 28 -45 M 28 -25 L -28 -45 M -24 -70 L 24 -88 M 24 -70 L -24 -88 M -19 -112 L 19 -126 M 19 -112 L -19 -126"
/>
<path d="M -58 -120 L 58 -120 M -46 -150 L 46 -150" />
<path d="M -58 -120 L -58 -110 M 58 -120 L 58 -110 M -46 -150 L -46 -140 M 46 -150 L 46 -140" />
</g>
<!-- 塔间导线 -->
<path v-for="line in powerLines" :key="line.path" :d="line.path" />
<!-- 风机 -->
<g
v-for="turbine in turbines"
:key="turbine.key"
:transform="`translate(${turbine.x}, 870) scale(${turbine.s})`"
>
<path d="M -3 0 L 0 -120 M 3 0 L 0 -120" />
<g transform="translate(0, -120)">
<g>
<path d="M 0 0 L 0 -52" />
<path d="M 0 0 L 0 -52" transform="rotate(120)" />
<path d="M 0 0 L 0 -52" transform="rotate(240)" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 0 0"
to="360 0 0"
:dur="turbine.dur"
repeatCount="indefinite"
/>
</g>
</g>
<circle cx="0" cy="-120" r="3" class="industry__hub" />
</g>
</g>
<!-- 导线上的电流光点 -->
<circle v-for="spark in lineSparks" :key="spark.key" r="2.5" class="travel-dot industry__spark">
<animateMotion :dur="spark.dur" :begin="spark.begin" repeatCount="indefinite" :path="spark.path" />
</circle>
<!-- 汇入节点脉冲 -->
<g v-for="point in mergePoints" :key="`merge-${point.x}`">
<circle :cx="point.x" cy="470" r="5" :fill="point.color" />
<circle :cx="point.x" cy="470" r="5" :stroke="point.color" class="merge-pulse" />
</g>
</svg>
<!-- 角色徽章 -->
<span v-for="chip in roleChips" :key="chip.label" class="role-chip" :style="chip.style">
<i class="role-chip__dot" :style="{ backgroundColor: chip.color }"></i>
{{ chip.label }}
</span>
</div> </div>
<!-- 顶部品牌 -->
<header class="scene-header reveal" style="--d: 0s">
<SystemLogo class="text-36px" />
<span class="scene-header__name">{{ $t('system.title') }}</span>
</header> </header>
<main class="pt-15px">
<div class="pt-15px"> <!-- 主文案 -->
<div class="scene-hero" :style="layerStyle(10)">
<p class="scene-hero__eyebrow reveal" style="--d: 0.15s">BUILD TOGETHER · GUARD POWER QUALITY</p>
<h1 class="scene-hero__slogan reveal" style="--d: 0.25s">
独行快
<span class="scene-hero__comma"></span>
<br />
众行
<em></em>
</h1>
<p class="scene-hero__sub reveal" style="--d: 0.4s">每一次提交都让电能质量的守护更进一步</p>
</div>
<!-- 登录卡片 -->
<div class="login-card">
<header class="login-card__header reveal" style="--d: 0.3s">
<SystemLogo class="login-card__logo text-52px" />
<h2 class="login-card__title">{{ $t('system.title') }}</h2>
<p class="login-card__subtitle">欢迎回来开始今天的协作</p>
</header>
<main class="reveal" style="--d: 0.45s">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear> <Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" /> <component :is="activeModule.component" />
</Transition> </Transition>
</div>
</main> </main>
</div> </div>
</ElCard>
<footer class="scene-footer reveal" style="--d: 0.6s">
© {{ currentYear }} 南京灿能电力自动化股份有限公司 · {{ $t('system.title') }}
</footer>
</div> </div>
</template> </template>
<style scoped></style> <style scoped lang="scss">
.login-scene {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
height: 100%;
padding-right: 9vw;
overflow: hidden;
background:
radial-gradient(90% 70% at 80% 42%, color-mix(in srgb, var(--brand) 10%, transparent) 0%, transparent 60%),
radial-gradient(80% 60% at 6% 92%, rgb(56 189 248 / 10%) 0%, transparent 60%),
radial-gradient(70% 50% at 18% 8%, rgb(14 165 233 / 6%) 0%, transparent 55%),
linear-gradient(160deg, #f5f9ff 0%, #ecf2fb 50%, #fafcff 100%);
@media (max-width: 1023px) {
justify-content: center;
padding-right: 0;
}
}
/* ---------- 远景浮尘微粒 ---------- */
.scene-motes {
position: absolute;
inset: -40px;
background-image:
radial-gradient(2px 2px at 12% 22%, rgb(30 128 223 / 30%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 28% 68%, rgb(23 44 84 / 16%) 50%, transparent 51%),
radial-gradient(2.5px 2.5px at 44% 12%, rgb(30 128 223 / 22%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 58% 44%, rgb(23 44 84 / 12%) 50%, transparent 51%),
radial-gradient(2px 2px at 72% 78%, rgb(56 189 248 / 25%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 86% 28%, rgb(23 44 84 / 14%) 50%, transparent 51%),
radial-gradient(2px 2px at 94% 62%, rgb(30 128 223 / 20%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 6% 86%, rgb(56 189 248 / 18%) 50%, transparent 51%);
background-size: 520px 520px;
background-repeat: repeat;
animation: motes-breathe 6s ease-in-out infinite alternate;
transition: transform 0.25s ease-out;
pointer-events: none;
}
@keyframes motes-breathe {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
/* ---------- 协作汇流图 ---------- */
.scene-graph {
position: absolute;
inset: 0;
transition: transform 0.25s ease-out;
pointer-events: none;
}
.scene-graph__svg {
width: 100%;
height: 100%;
}
.trunk {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
}
.branch {
fill: none;
stroke-width: 2;
stroke-linecap: round;
animation: branch-breathe 6s ease-in-out infinite alternate;
animation-delay: var(--breathe-d, 0s);
}
@keyframes branch-breathe {
from {
opacity: 0.55;
}
to {
opacity: 1;
}
}
.travel-dot {
filter: drop-shadow(0 0 6px currentColor);
}
.wave {
fill: none;
stroke-linecap: round;
animation: wave-drift var(--wave-dur) linear infinite;
}
@keyframes wave-drift {
to {
transform: translateX(var(--wave-shift));
}
}
/* 电力场景剪影 */
.industry {
fill: none;
stroke: #424a8c;
stroke-width: 1.5;
stroke-linecap: round;
opacity: 0.22;
}
.industry__hub {
fill: #424a8c;
stroke: none;
}
.industry__spark {
fill: var(--brand);
color: var(--brand);
opacity: 0.65;
}
.merge-pulse {
fill: none;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: merge-pulse 2.6s ease-out infinite;
}
@keyframes merge-pulse {
0% {
opacity: 0.8;
transform: scale(1);
}
70%,
100% {
opacity: 0;
transform: scale(3.2);
}
}
/* 角色徽章 */
.role-chip {
position: absolute;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border: 1px solid rgb(30 35 80 / 10%);
border-radius: 999px;
font-size: 13px;
letter-spacing: 0.14em;
color: rgb(30 35 80 / 72%);
background: rgb(255 255 255 / 65%);
box-shadow: 0 6px 18px -8px rgb(23 92 171 / 22%);
backdrop-filter: blur(8px);
animation: chip-float 5.5s ease-in-out infinite alternate;
animation-delay: var(--float-d, 0s);
}
.role-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
box-shadow: 0 0 8px 1px currentcolor;
}
@keyframes chip-float {
from {
transform: translateY(-6px);
}
to {
transform: translateY(8px);
}
}
/* ---------- 品牌与文案 ---------- */
.scene-header {
position: absolute;
top: 40px;
left: 56px;
z-index: 2;
display: flex;
align-items: center;
gap: 12px;
color: #232850;
}
.scene-header__name {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.08em;
}
.scene-hero {
position: absolute;
top: 24%;
left: 6.5%;
z-index: 2;
color: #1b2050;
transition: transform 0.25s ease-out;
pointer-events: none;
@media (max-width: 1023px) {
display: none;
}
}
.scene-hero__eyebrow {
margin-bottom: 26px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
letter-spacing: 0.46em;
color: rgb(30 35 80 / 45%);
}
.scene-hero__slogan {
font-size: 64px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.14em;
text-shadow: 0 8px 32px rgb(255 255 255 / 70%);
em {
font-style: normal;
background: linear-gradient(120deg, var(--brand) 0%, #0b66c3 55%, #38bdf8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.scene-hero__comma {
color: rgb(30 35 80 / 28%);
}
.scene-hero__sub {
margin-top: 26px;
font-size: 16px;
letter-spacing: 0.22em;
color: rgb(30 35 80 / 52%);
}
.scene-footer {
position: absolute;
bottom: 26px;
left: 56px;
z-index: 2;
font-size: 12px;
letter-spacing: 0.1em;
color: rgb(30 35 80 / 32%);
}
/* ---------- 登录卡片(白玻璃质感) ---------- */
.login-card {
position: relative;
z-index: 3;
width: 420px;
padding: 44px 40px 40px;
border: 1px solid rgb(255 255 255 / 75%);
border-radius: 20px;
background: linear-gradient(168deg, rgb(255 255 255 / 82%) 0%, rgb(248 250 255 / 88%) 100%);
backdrop-filter: blur(20px) saturate(140%);
box-shadow:
0 30px 70px -24px rgb(23 92 171 / 26%),
0 0 0 1px rgb(30 35 80 / 5%),
0 1px 0 rgb(255 255 255 / 90%) inset;
}
.login-card__header {
margin-bottom: 32px;
text-align: center;
}
.login-card__logo {
display: block;
margin: 0 auto 16px;
filter: drop-shadow(0 10px 26px color-mix(in srgb, var(--brand) 35%, transparent));
}
.login-card__title {
font-size: 24px;
font-weight: 600;
letter-spacing: 0.1em;
color: #20254d;
}
.login-card__subtitle {
margin-top: 10px;
font-size: 13.5px;
letter-spacing: 0.06em;
color: rgb(30 35 80 / 48%);
}
/* 卡片内表单:浅色质感统一覆盖(作用于子模块) */
.login-card :deep(.el-input__wrapper) {
height: 48px;
padding: 0 14px;
border-radius: 10px;
background-color: rgb(30 128 223 / 5%);
box-shadow: 0 0 0 1px rgb(30 35 80 / 12%) inset;
transition:
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand) 55%, rgb(30 35 80 / 20%)) inset;
}
&.is-focus {
background-color: #fff;
box-shadow:
0 0 0 1.5px var(--brand) inset,
0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent);
}
.el-input__inner {
color: #1f244a;
caret-color: var(--brand);
&::placeholder {
color: rgb(30 35 80 / 34%);
}
}
.el-input__prefix,
.el-input__suffix {
font-size: 18px;
color: rgb(30 35 80 / 35%);
}
.el-input__prefix {
margin-right: 6px;
}
}
.login-card :deep(.el-form-item) {
margin-bottom: 22px;
}
.login-card :deep(.el-checkbox__label) {
letter-spacing: 0.04em;
color: rgb(30 35 80 / 58%);
}
/* 选中态跟随登录页品牌蓝,而非系统主题色 */
.login-card :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
border-color: var(--brand);
background-color: var(--brand);
}
.login-card :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
color: var(--brand);
}
.login-card :deep(.login-submit-button) {
position: relative;
width: 100%;
height: 48px;
margin-top: 4px;
border: none;
border-radius: 10px;
overflow: hidden;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.32em;
text-indent: 0.32em;
background: linear-gradient(135deg, var(--brand) 0%, color-mix(in srgb, var(--brand) 68%, #0a3f8f) 100%);
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--brand) 60%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
/* 流光扫过 */
&::after {
content: '';
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(100deg, transparent 0%, rgb(255 255 255 / 35%) 50%, transparent 100%);
transform: skewX(-20deg);
transition: left 0.55s ease;
}
&:hover {
transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 16px 32px -10px color-mix(in srgb, var(--brand) 70%, transparent);
&::after {
left: 130%;
}
}
&:active {
transform: translateY(0);
}
}
.login-card :deep(.login-back-button) {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border: 1px solid rgb(30 35 80 / 15%);
border-radius: 10px;
color: rgb(30 35 80 / 70%);
background: transparent;
&:hover {
border-color: color-mix(in srgb, var(--brand) 60%, transparent);
color: var(--brand);
background: color-mix(in srgb, var(--brand) 6%, transparent);
}
}
/* ---------- 入场动效 ---------- */
.reveal {
animation: reveal-up 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0s);
}
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,85 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { getPaletteColorByNumber, mixColor } from '@sa/color';
import { loginModuleRecord } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import PwdLogin from './modules/pwd-login.vue';
import ResetPwd from './modules/reset-pwd.vue';
defineOptions({ name: 'LoginPage' });
interface Props {
/** The login module */
module?: UnionKey.LoginModule;
}
const props = defineProps<Props>();
const appStore = useAppStore();
const themeStore = useThemeStore();
interface LoginModule {
label: App.I18n.I18nKey;
component: Component;
}
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd }
};
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
const bgThemeColor = computed(() =>
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
);
const bgColor = computed(() => {
const COLOR_WHITE = '#ffffff';
const ratio = themeStore.darkMode ? 0.5 : 0.2;
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
});
</script>
<template>
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
<WaveBg :theme-color="bgThemeColor" />
<ElCard class="relative z-4 w-auto rd-12px">
<div class="w-400px lt-sm:w-300px">
<header class="flex-y-center justify-between">
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
<div class="i-flex-col">
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:show-tooltip="false"
class="text-20px lt-sm:text-18px"
@switch="themeStore.toggleThemeScheme"
/>
<LangSwitch
v-if="themeStore.header.multilingual.visible"
:lang="appStore.locale"
:lang-options="appStore.localeOptions"
:show-tooltip="false"
@change-lang="appStore.changeLocale"
/>
</div>
</header>
<main class="pt-15px">
<div class="pt-15px">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</div>
</main>
</div>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -36,24 +36,38 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
<ElFormItem prop="userName"> <ElFormItem prop="userName">
<ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')" /> <ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:account-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="password"> <ElFormItem prop="password">
<ElInput <ElInput
v-model="model.password" v-model="model.password"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.passwordPlaceholder')" :placeholder="$t('page.login.common.passwordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElSpace direction="vertical" :size="24" class="w-full" fill> <div class="pb-18px">
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox> <ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
<ElButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit"> </div>
{{ $t('common.confirm') }} <ElButton
type="primary"
size="large"
class="login-submit-button"
:loading="authStore.loginLoading"
@click="handleSubmit"
>
{{ $t('route.login') }}
</ElButton> </ElButton>
</ElSpace>
</ElForm> </ElForm>
</template> </template>

View File

@@ -43,38 +43,60 @@ async function handleSubmit() {
</script> </script>
<template> <template>
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit"> <ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
<ElFormItem prop="phone"> <ElFormItem prop="phone">
<ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" /> <ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:cellphone" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="code"> <ElFormItem prop="code">
<ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')" /> <ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')">
<template #prefix>
<SvgIcon icon="mdi:shield-check-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="password"> <ElFormItem prop="password">
<ElInput <ElInput
v-model="model.password" v-model="model.password"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.passwordPlaceholder')" :placeholder="$t('page.login.common.passwordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElFormItem prop="confirmPassword"> <ElFormItem prop="confirmPassword">
<ElInput <ElInput
v-model="model.confirmPassword" v-model="model.confirmPassword"
type="password" type="password"
show-password-on="click" show-password
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')" :placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
/> >
<template #prefix>
<SvgIcon icon="mdi:lock-check-outline" />
</template>
</ElInput>
</ElFormItem> </ElFormItem>
<ElSpace direction="vertical" fill :size="18" class="w-full"> <ElButton type="primary" size="large" class="login-submit-button" @click="handleSubmit">
<ElButton type="primary" size="large" round @click="handleSubmit">
{{ $t('common.confirm') }} {{ $t('common.confirm') }}
</ElButton> </ElButton>
<ElButton size="large" round @click="toggleLoginModule('pwd-login')"> <ElButton size="large" class="login-back-button" @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }} {{ $t('page.login.common.back') }}
</ElButton> </ElButton>
</ElSpace>
</ElForm> </ElForm>
</template> </template>
<style scoped></style> <style scoped>
.login-back-button {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border-radius: 10px;
}
</style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
const route = useRoute();
const { routerPushByKey } = useRouterPush();
const routeQuery = computed(() => JSON.stringify(route.query));
</script>
<template>
<div>
<LookForward>
<div>
<ElButton @click="routerPushByKey('function_tab')">{{ $t('page.function.multiTab.backTab') }}</ElButton>
<div class="py-24px">{{ $t('page.function.multiTab.routeParam') }}: {{ routeQuery }}</div>
</div>
</LookForward>
</div>
</template>
<style scoped></style>

View File

@@ -1,57 +0,0 @@
<script setup lang="ts">
import { fetchCustomBackendError } from '@/service/api';
import { $t } from '@/locales';
async function logout() {
await fetchCustomBackendError('8888', $t('request.logoutMsg'));
}
async function logoutWithModal() {
await fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'));
}
async function refreshToken() {
await fetchCustomBackendError('9999', $t('request.tokenExpired'));
}
async function handleRepeatedMessageError() {
await Promise.all([
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2'))
]);
}
async function handleRepeatedModalError() {
await Promise.all([
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'))
]);
}
</script>
<template>
<ElSpace direction="vertical" fill :size="16">
<ElCard :header="$t('request.logout')" class="card-wrapper">
<ElButton @click="logout">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('request.logoutWithModal')" class="card-wrapper">
<ElButton @click="logoutWithModal">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('request.refreshToken')" class="card-wrapper">
<ElButton @click="refreshToken">{{ $t('common.trigger') }}</ElButton>
</ElCard>
<ElCard :header="$t('page.function.request.repeatedErrorOccurOnce')" class="card-wrapper">
<ElButton @click="handleRepeatedMessageError">{{ $t('page.function.request.repeatedError') }}(Message)</ElButton>
<ElButton class="ml-12px" @click="handleRepeatedModalError">
{{ $t('page.function.request.repeatedError') }}(Modal)
</ElButton>
</ElCard>
</ElSpace>
</template>
<style scoped></style>

Some files were not shown because too many files have changed in this diff Show More