Compare commits

..

36 Commits

Author SHA1 Message Date
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
228 changed files with 44001 additions and 4940 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)"
]
}
}

4
.env
View File

@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
# 典型场景token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
VITE_SERVICE_LOGOUT_CODES=401,1002023000
VITE_SERVICE_LOGOUT_CODES=401
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
# 典型场景accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
# 静态路由模式下定义的超级管理员角色
VITE_STATIC_SUPER_ROLE=R_SUPER

4
.gitignore vendored
View File

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

View File

@@ -11,6 +11,8 @@
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
## 交互与执行原则
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
@@ -173,6 +175,31 @@
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts``src/router/routes/*``src/store/modules/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` 初始化;不要在业务页面重复直调字典接口。
@@ -235,6 +262,7 @@ const directionLabels = getLabels(row.directionCodes, { separator: '' });
- 如果后端当前接口暂时还返回数值型 ID前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 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当前任务触达相关链路时优先顺手矫正”不要继续复制历史写法。
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
- 修改界面时优先延续 `src/layouts``src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。

View File

@@ -10,6 +10,7 @@
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
- **不主动执行 git 操作**status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
@@ -284,6 +285,15 @@ const directionLabels = getLabels(row.directionCodes, { separator: '' });
- **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。
- 最稳妥契约:**后端 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` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。
@@ -368,3 +378,54 @@ pnpm preview # preview server (9725)
- 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)`
- 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map调用方写一个 wrapper 即可。
- 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 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 { bgRed, bgYellow, green, lightBlue } from 'kolorist';
import { consola } from 'consola';
import { WEB_SERVICE_PREFIX } from '../../src/constants/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));
});
// 富文本图片 <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;
}

View File

@@ -27,8 +27,13 @@ export function setupElegantRouter() {
onRouteMetaGen(routeName) {
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>>> = {
workbench: {
icon: 'mdi:view-dashboard-outline',
order: 1,
keepAlive: true
},
product: {
icon: 'carbon:product',
order: 4
@@ -79,6 +84,78 @@ export function setupElegantRouter() {
hideInMenu: true,
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_my-weekly': {
icon: 'mdi:calendar-week-outline',
order: 2,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
order: 3,
keepAlive: true
},
'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_pending-approval': {
icon: 'mdi:check-decagram-outline',
order: 6,
keepAlive: true
},
system: {
icon: 'carbon:cloud-service-management',
order: 9,
@@ -110,6 +187,20 @@ export function setupElegantRouter() {
hideInMenu: true,
roles: ['R_ADMIN'],
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-05-19T07:08:28.081Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": {
"directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 8,
"total": 22,
"items": [
{
"name": "product_list",
@@ -74,6 +74,402 @@
"pageType": "leaf",
"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_my-weekly",
"path": "/personal-center/my-weekly",
"component": "view.personal-center_my-weekly",
"title": "我的周报",
"routeTitle": "personal-center_my-weekly",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-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-weekly",
"icon": "mdi:calendar-week-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_my-monthly",
"path": "/personal-center/my-monthly",
"component": "view.personal-center_my-monthly",
"title": "我的月报",
"routeTitle": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的月报",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"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": 3,
"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": 3,
"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": 4,
"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": 4,
"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": 5,
"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": 5,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "system_user",
"path": "/system/user",
@@ -271,6 +667,72 @@
"parentName": "system",
"pageType": "leaf",
"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

@@ -41,6 +41,7 @@
"@antv/g2": "5.4.0",
"@antv/g6": "5.0.49",
"@better-scroll/core": "2.5.1",
"@iconify-vue/mingcute": "^1.0.5",
"@iconify/vue": "5.0.0",
"@sa/axios": "workspace:*",
"@sa/color": "workspace:*",
@@ -90,7 +91,6 @@
"@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.7.1",
"@types/bmapgl": "0.0.7",
"@types/dompurify": "3.2.0",
"@types/node": "24.3.0",
"@types/nprogress": "0.2.3",
"@unocss/eslint-config": "66.5.0",

33
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@better-scroll/core':
specifier: 2.5.1
version: 2.5.1
'@iconify-vue/mingcute':
specifier: ^1.0.5
version: 1.0.5(vue@3.5.20(typescript@5.8.3))
'@iconify/vue':
specifier: 5.0.0
version: 5.0.0(vue@3.5.20(typescript@5.8.3))
@@ -162,9 +165,6 @@ importers:
'@types/bmapgl':
specifier: 0.0.7
version: 0.0.7
'@types/dompurify':
specifier: 3.2.0
version: 3.2.0
'@types/node':
specifier: 24.3.0
version: 24.3.0
@@ -857,6 +857,14 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-vue/mingcute@1.0.5':
resolution: {integrity: sha512-9g/iEU2XdobbfS6vKp01btfBlPiMqlqa+GujwYOc5WVJierhKt3dF0+tamomdk9vYcIsJiGcqOaKvrJF0g6prA==}
'@iconify/css-vue@1.0.2':
resolution: {integrity: sha512-KXG9zXTMmJLi1AF2ket+YWUGdSqFvIMSnCO789uOVpba6SZhqeUttu0JIaEcq2dNlt4oonwdtMyerkpRkAFYhw==}
peerDependencies:
vue: '>=3.0.0'
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -1491,10 +1499,6 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6180,6 +6184,17 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-vue/mingcute@1.0.5(vue@3.5.20(typescript@5.8.3))':
dependencies:
'@iconify/css-vue': 1.0.2(vue@3.5.20(typescript@5.8.3))
transitivePeerDependencies:
- vue
'@iconify/css-vue@1.0.2(vue@3.5.20(typescript@5.8.3))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.20(typescript@5.8.3)
'@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
@@ -6695,10 +6710,6 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.2.6
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1

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">
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
import '@wangeditor/editor/dist/css/style.css';
import { ElImageViewer } from 'element-plus';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
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' });
@@ -28,6 +29,140 @@ const props = withDefaults(defineProps<Props>(), {
const model = defineModel<string | null | undefined>({ default: '' });
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> = {
excludeKeys: [
@@ -63,8 +198,12 @@ const editorConfig: Partial<IEditorConfig> = {
return;
}
const url = result.data;
insertFn(url, file.name, url);
// 用永久代理路径塞 <img src>,不要用 result.data.url24h 签名会过期)
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) {
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(() => {
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 = undefined;
});
@@ -116,7 +362,7 @@ const editorStyle = computed(() => {
</script>
<template>
<div :class="containerClass">
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
<Toolbar
class="business-rich-text-editor__toolbar"
:editor="editorRef"
@@ -131,11 +377,36 @@ const editorStyle = computed(() => {
mode="default"
@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>
</template>
<style scoped lang="scss">
.business-rich-text-editor {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
@@ -157,6 +428,27 @@ const editorStyle = computed(() => {
height: 100%;
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 遮挡 */

View File

@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
<template>
<div class="business-rich-text-view">
<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>
</template>

View File

@@ -1,12 +1,13 @@
import { computed, defineComponent, ref } from 'vue';
import type { PropType } from 'vue';
import { ElButton, ElPopover } from 'element-plus';
import { computed, defineComponent, h, ref } from 'vue';
import type { Component, PropType } from 'vue';
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
import { $t } from '@/locales';
export type BusinessTableAction = {
key: string;
label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
icon?: Component;
disabled?: boolean;
onClick: () => void | Promise<void>;
};
@@ -17,12 +18,20 @@ export default defineComponent({
actions: {
type: Array as PropType<BusinessTableAction[]>,
required: true
},
variant: {
type: String as PropType<'button' | 'icon'>,
default: 'button'
}
},
setup(props) {
const popoverVisible = ref(false);
const directActions = computed(() => {
if (props.variant === 'icon') {
return props.actions;
}
if (props.actions.length <= 2) {
return props.actions;
}
@@ -31,6 +40,10 @@ export default defineComponent({
});
const moreActions = computed(() => {
if (props.variant === 'icon') {
return [];
}
if (props.actions.length <= 2) {
return [];
}
@@ -47,21 +60,86 @@ export default defineComponent({
await action.onClick();
}
return () => (
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
{directActions.value.map(action => (
function renderIcon(action: BusinessTableAction) {
if (!action.icon) return null;
return h(action.icon, { class: 'business-table-action-icon' });
}
function renderButtonAction(action: BusinessTableAction) {
return (
<ElButton
key={action.key}
plain
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-button"
onClick={() => handleAction(action)}
>
{action.label}
</ElButton>
);
}
function renderIconAction(action: BusinessTableAction) {
return (
<ElTooltip key={action.key} content={action.label} placement="top">
<ElButton
key={action.key}
plain
link
size="small"
type={action.buttonType}
disabled={action.disabled}
class="business-table-action-button"
class="business-table-action-icon-button"
aria-label={action.label}
onClick={() => handleAction(action)}
>
{action.label}
{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 && (
<ElPopover
@@ -74,32 +152,28 @@ export default defineComponent({
{{
reference: () => (
<ElButton
plain
link={props.variant === 'icon'}
plain={props.variant !== 'icon'}
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()}
>
<span class="inline-flex items-center gap-4px">
{$t('common.more')}
<icon-mdi-chevron-down class="text-14px" />
</span>
{props.variant === 'icon' ? (
<icon-mdi-dots-horizontal class="business-table-action-icon" />
) : (
<span class="inline-flex items-center gap-4px">
{$t('common.more')}
<icon-mdi-chevron-down class="text-14px" />
</span>
)}
</ElButton>
),
default: () => (
<div class="business-table-action-menu">
{moreActions.value.map(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>
))}
{moreActions.value.map(action => renderMenuButton(action))}
</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">
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { useDict } from '@/hooks/business/dict';
defineOptions({ name: 'DictSelect' });
const ensuredEmptyDictCodes = new Set<string>();
interface Props {
dictCode: string;
placeholder?: string;
@@ -14,6 +17,8 @@ interface Props {
multiple?: boolean;
collapseTags?: boolean;
collapseTagsTooltip?: boolean;
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
showRemark?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
onlyEnabled: true,
multiple: false,
collapseTags: false,
collapseTagsTooltip: false
collapseTagsTooltip: false,
showRemark: false
});
const model = defineModel<string | number | Array<string | number> | null | undefined>({
default: undefined
});
const dictStore = useDictStore();
const { enabledDictData, dictData } = useDict(() => props.dictCode);
const dictOptions = computed(() => {
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
return source.map(item => ({
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>
<template>
<ElSelect
v-model="model"
class="w-full"
class="dict-select w-full"
:placeholder="props.placeholder"
:disabled="props.disabled"
:clearable="props.clearable"
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
:collapse-tags="props.collapseTags"
: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>
</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">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
import DictText from './dict-text.vue';
defineOptions({ name: 'DictTag' });
@@ -14,6 +16,7 @@ interface Props {
fallback?: string;
separator?: string;
onlyEnabled?: boolean;
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
type?: DictTagType;
effect?: DictTagEffect;
size?: DictTagSize;
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
size: 'default',
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>
<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
:dict-code="props.dictCode"
:value="props.value"

View File

@@ -2,6 +2,13 @@
import { $t } from '@/locales';
defineOptions({ name: 'LookForward' });
interface Props {
title?: string;
subtitle?: string;
}
defineProps<Props>();
</script>
<template>
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
<SvgIcon local-icon="expectation" />
</div>
<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>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { VNode } from 'vue';
import { ElButton, ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import DictSelect from './dict-select.vue';
@@ -23,8 +24,12 @@ export interface SearchField {
options?: Option[];
/** dict 类型的字典编码 */
dictCode?: string;
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急" */
showRemark?: boolean;
/** 占位提示文本 */
placeholder?: string;
/** select 类型的自定义选项渲染函数 */
renderOption?: (option: Option) => VNode | VNode[] | string;
}
interface Props {
@@ -142,7 +147,11 @@ function handleSearch() {
:disabled="props.disabled"
@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>
<ElDatePicker
v-else-if="field.type === 'date'"
@@ -172,6 +181,7 @@ function handleSearch() {
:dict-code="field.dictCode!"
:placeholder="field.placeholder"
:disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)"
/>
</ElFormItem>
@@ -234,7 +244,11 @@ function handleSearch() {
:disabled="props.disabled"
@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>
<ElDatePicker
v-else-if="field.type === 'date'"
@@ -264,6 +278,7 @@ function handleSearch() {
:dict-code="field.dictCode!"
:placeholder="field.placeholder"
:disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)"
/>
</ElFormItem>

View File

@@ -89,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
};
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';
/**
* 需求优先级字典编码
* 优先级字典编码
*
* 对应业务字段:需求相关接口和页面中的 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';
@@ -75,3 +79,35 @@ export const RDMS_PROJECT_TYPE_DICT_CODE = 'rdms_project_type';
* 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 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';

View File

@@ -10,11 +10,13 @@ export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger
export type StatusDomain =
| 'projectExecution'
| 'projectTask'
| 'executionMember'
| 'executionAssignee'
| 'taskAssigneeMember'
| 'project'
| 'product'
| 'requirement'
| 'workOrder';
| 'workOrder'
| 'personalItem';
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
// 项目-执行
@@ -29,17 +31,22 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
projectTask: {
pending: 'info',
active: 'primary',
blocked: 'warning',
paused: 'warning',
completed: 'success',
cancelled: 'danger'
},
// 执行成员变更事件
executionMember: {
// 执行协办人变更事件
executionAssignee: {
join: 'success',
inactive: 'danger',
owner_transfer_in: 'warning',
owner_transfer_out: 'warning'
},
// 任务协办人变更事件
taskAssigneeMember: {
join: 'success',
inactive: 'danger'
},
// 项目(待补全)
project: {},
// 产品(待补全)
@@ -47,7 +54,14 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
// 需求(待补全)
requirement: {},
// 工单(待补全)
workOrder: {}
workOrder: {},
// 个人事项
personalItem: {
pending: 'info',
active: 'primary',
completed: 'success',
cancelled: 'danger'
}
};
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
@@ -57,3 +71,7 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
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',
Route = 'route-store',
Tab = 'tab-store',
ObjectContext = 'object-context-store'
ObjectContext = 'object-context-store',
Workbench = 'workbench-store'
}

View File

@@ -0,0 +1,457 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useInfiniteScroll } from '@vueuse/core';
defineOptions({ name: 'NotificationBell' });
interface NotificationItem {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const PAGE_SIZE = 10;
// 通知 mock扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
function buildMockNotifications(): NotificationItem[] {
const titles = [
'你被指派为执行「迭代 24.06」负责人',
'任务「SSO 改造」状态变更:开发中 → 待验收',
'需求「多币种支持」评审通过',
'工单 #1042 已分派给你',
'需求「订单导出」被退回,请补充材料',
'@ 你的评论已被回复',
'项目「客户中心 2.0」周报已生成',
'工单 #1098 客户回复待处理',
'执行「迭代 24.05」已结束',
'需求「批量审批」分配给你'
];
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
return Array.from({ length: 60 }, (_, i) => ({
id: `m${i + 1}`,
title: `${titles[i % titles.length]}#${i + 1}`,
timeLabel: times[Math.floor(i / 6) % times.length],
unread: i < 14
}));
}
const notifications = ref<NotificationItem[]>(buildMockNotifications());
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
const readAll = computed(() => notifications.value.filter(n => !n.unread));
const unreadCount = computed(() => unreadAll.value.length);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false);
const activeTab = ref<'unread' | 'read'>('unread');
const searchKeyword = ref('');
function matchesKeyword(item: NotificationItem) {
const kw = searchKeyword.value.trim();
if (!kw) return true;
return item.title.toLowerCase().includes(kw.toLowerCase());
}
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
const unreadPageSize = ref(PAGE_SIZE);
const readPageSize = ref(PAGE_SIZE);
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
watch(searchKeyword, () => {
unreadPageSize.value = PAGE_SIZE;
readPageSize.value = PAGE_SIZE;
});
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null);
const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef,
() => {
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
},
{ distance: 48 }
);
useInfiniteScroll(
() => readScrollbar.value?.wrapRef,
() => {
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
},
{ distance: 48 }
);
function openDrawer() {
drawerOpen.value = true;
}
function closeDrawer() {
drawerOpen.value = false;
}
function markRead(item: NotificationItem) {
if (!item.unread) return;
item.unread = false;
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', item.id);
}
function markAllRead() {
notifications.value.forEach(item => {
item.unread = false;
});
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
function openItem(item: NotificationItem) {
markRead(item);
// eslint-disable-next-line no-console
console.warn('[notification] open', item.id);
}
function onDrawerClosed() {
searchKeyword.value = '';
}
</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" :with-header="false" @closed="onDrawerClosed">
<div class="notification-bell__panel">
<header class="notification-bell__header">
<span class="notification-bell__title">
通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span>
<span class="notification-bell__header-actions">
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
<SvgIcon icon="mdi:close" />
</button>
</span>
</header>
<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">{{ filteredUnread.length }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
<li
v-for="row in visibleUnread"
:key="row.id"
class="notification-bell__row is-unread"
@click="openItem(row)"
>
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div>
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
<ElTabPane name="read">
<template #label>
<span class="notification-bell__tab-label">
已读
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div>
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</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: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.notification-bell__panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-bell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.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__header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.notification-bell__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
transition:
background-color 120ms ease,
color 120ms ease;
}
.notification-bell__close:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.notification-bell__search {
padding: 12px 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;
cursor: pointer;
border-radius: 8px;
transition: background-color 120ms ease;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row: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 { SvgIconVNode } = useSvgIcon();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
function loginOrRegister() {
toLogin();
}
type DropdownKey = 'user-center' | 'logout';
type DropdownKey = 'personal-center_my-profile' | 'logout';
type DropdownOption = {
key: DropdownKey;
@@ -27,8 +29,8 @@ type DropdownOption = {
const options = computed(() => {
const opts: DropdownOption[] = [
{
label: $t('common.userCenter'),
key: 'user-center',
label: $t('common.myProfile'),
key: 'personal-center_my-profile',
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
},
{
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
</template>
<div class="flex items-center">
<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>
</ElDropdown>
</template>

View File

@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue';
import ThemeButton from './components/theme-button.vue';
import NotificationBell from './components/notification-bell.vue';
import UserAvatar from './components/user-avatar.vue';
defineOptions({ name: 'GlobalHeader' });
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
<div>
<ThemeButton />
</div>
<NotificationBell />
<UserAvatar />
</div>
</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 { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue';
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
import { useMenu, useMixMenuContext } from '../../../context';
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)]"
></div>
<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
v-if="showObjectContextInfo && headerMenus.length"
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
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 {
display: flex;
align-items: center;

View File

@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: 'Trigger',
update: 'Update',
updateSuccess: 'Update Success',
userCenter: 'User Center',
myProfile: 'My Profile',
yesOrNo: {
yes: 'Yes',
no: 'No'
@@ -158,7 +158,25 @@ const local: App.I18n.Schema = {
404: 'Page Not Found',
500: 'Server Error',
'iframe-page': 'Iframe',
'user-center': 'User Center',
workbench: 'Workbench',
ticket: 'Ticket',
'ticket_my-submitted': 'My Submitted',
'ticket_my-pending': 'My Pending',
metrics: 'Metrics',
'metrics_project-progress': 'Project Progress',
'metrics_member-efficiency': 'Member Efficiency',
metrics_worktime: 'Worktime',
'personal-center': 'Personal Center',
'personal-center_my-profile': 'My Profile',
'personal-center_my-item': 'My Items',
'personal-center_my-weekly': 'My Weekly Report',
'personal-center_my-monthly': 'My Monthly Report',
'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application',
'personal-center_pending-approval': 'Pending Approval',
infra: 'Infra',
'infra_state-machine': 'State Machine',
'infra_rd-code': 'R&D Code',
function: 'System Function',
function_tab: 'Tab',
'function_multi-tab': 'Multi Tab',
@@ -199,9 +217,6 @@ const local: App.I18n.Schema = {
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',
@@ -495,6 +510,7 @@ const local: App.I18n.Schema = {
orgType: {
company: 'Company',
dept: 'Department',
function: 'Functional Department',
direction: 'Direction',
team: 'Team'
},

View File

@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: '触发',
update: '更新',
updateSuccess: '更新成功',
userCenter: '个人中心',
myProfile: '个人信息',
yesOrNo: {
yes: '是',
no: '否'
@@ -158,7 +158,25 @@ const local: App.I18n.Schema = {
404: '页面不存在',
500: '服务器错误',
'iframe-page': '外链页面',
'user-center': '个人中心',
workbench: '工作台',
ticket: '工单',
'ticket_my-submitted': '我提交的工单',
'ticket_my-pending': '待我处理的工单',
metrics: '效能度量',
'metrics_project-progress': '项目进度',
'metrics_member-efficiency': '员工能效',
metrics_worktime: '工时统计',
'personal-center': '个人中心',
'personal-center_my-profile': '个人信息',
'personal-center_my-item': '我的事项',
'personal-center_my-weekly': '我的周报',
'personal-center_my-monthly': '我的月报',
'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请',
'personal-center_pending-approval': '待我审批',
infra: '基础设施',
'infra_state-machine': '状态机管理',
'infra_rd-code': '研发令号',
function: '系统功能',
function_tab: '标签页',
'function_multi-tab': '多标签页',
@@ -199,9 +217,6 @@ const local: App.I18n.Schema = {
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: '打印',
@@ -491,6 +506,7 @@ const local: App.I18n.Schema = {
orgType: {
company: '公司',
dept: '部门',
function: '职能部门',
direction: '方向',
team: '团队'
},

View File

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

View File

@@ -28,13 +28,23 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"function_super-page": () => import("@/views/function/super-page/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"),
plugin_editor_quill: () => import("@/views/plugin/editor/quill/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"),
@@ -63,5 +73,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
"system_user-management-relation": () => import("@/views/system/user-management-relation/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

@@ -170,6 +170,43 @@ export const generatedRoutes: GeneratedRoute[] = [
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',
path: '/login/:module(pwd-login|reset-pwd)?',
@@ -182,6 +219,152 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'metrics',
path: '/metrics',
component: 'layout.base',
meta: {
title: 'metrics',
i18nKey: 'route.metrics',
icon: 'mdi:chart-line',
order: 7
},
children: [
{
name: 'metrics_member-efficiency',
path: '/metrics/member-efficiency',
component: 'view.metrics_member-efficiency',
meta: {
title: 'metrics_member-efficiency',
i18nKey: 'route.metrics_member-efficiency',
icon: 'mdi:account-multiple-check-outline',
order: 2,
keepAlive: true
}
},
{
name: 'metrics_project-progress',
path: '/metrics/project-progress',
component: 'view.metrics_project-progress',
meta: {
title: 'metrics_project-progress',
i18nKey: 'route.metrics_project-progress',
icon: 'mdi:progress-clock',
order: 1,
keepAlive: true
}
},
{
name: 'metrics_worktime',
path: '/metrics/worktime',
component: 'view.metrics_worktime',
meta: {
title: 'metrics_worktime',
i18nKey: 'route.metrics_worktime',
icon: 'mdi:clock-time-five-outline',
order: 3,
keepAlive: true
}
}
]
},
{
name: 'personal-center',
path: '/personal-center',
component: 'layout.base',
meta: {
title: 'personal-center',
i18nKey: 'route.personal-center',
icon: 'mdi:account-circle-outline',
order: 8
},
children: [
{
name: 'personal-center_my-application',
path: '/personal-center/my-application',
component: 'view.personal-center_my-application',
meta: {
title: 'personal-center_my-application',
i18nKey: 'route.personal-center_my-application',
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-monthly',
path: '/personal-center/my-monthly',
component: 'view.personal-center_my-monthly',
meta: {
title: 'personal-center_my-monthly',
i18nKey: 'route.personal-center_my-monthly',
icon: 'mdi:calendar-month-outline',
order: 2,
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_my-weekly',
path: '/personal-center/my-weekly',
component: 'view.personal-center_my-weekly',
meta: {
title: 'personal-center_my-weekly',
i18nKey: 'route.personal-center_my-weekly',
icon: 'mdi:calendar-week-outline',
order: 1,
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: 5,
keepAlive: true
}
}
]
},
{
name: 'plugin',
path: '/plugin',
@@ -254,37 +437,6 @@ export const generatedRoutes: GeneratedRoute[] = [
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',
@@ -664,13 +816,53 @@ export const generatedRoutes: GeneratedRoute[] = [
]
},
{
name: 'user-center',
path: '/user-center',
component: 'layout.base$view.user-center',
name: 'ticket',
path: '/ticket',
component: 'layout.base',
meta: {
title: 'user-center',
i18nKey: 'route.user-center',
hideInMenu: true
title: 'ticket',
i18nKey: 'route.ticket',
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

@@ -181,7 +181,22 @@ const routeMap: RouteMap = {
"function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth",
"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)?",
"metrics": "/metrics",
"metrics_member-efficiency": "/metrics/member-efficiency",
"metrics_project-progress": "/metrics/project-progress",
"metrics_worktime": "/metrics/worktime",
"personal-center": "/personal-center",
"personal-center_my-application": "/personal-center/my-application",
"personal-center_my-item": "/personal-center/my-item",
"personal-center_my-monthly": "/personal-center/my-monthly",
"personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin",
"plugin_barcode": "/plugin/barcode",
"plugin_charts": "/plugin/charts",
@@ -189,9 +204,6 @@ const routeMap: RouteMap = {
"plugin_charts_echarts": "/plugin/charts/echarts",
"plugin_charts_vchart": "/plugin/charts/vchart",
"plugin_copy": "/plugin/copy",
"plugin_editor": "/plugin/editor",
"plugin_editor_markdown": "/plugin/editor/markdown",
"plugin_editor_quill": "/plugin/editor/quill",
"plugin_excel": "/plugin/excel",
"plugin_gantt": "/plugin/gantt",
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
@@ -226,7 +238,10 @@ const routeMap: RouteMap = {
"system_user": "/system/user",
"system_user-detail": "/system/user-detail/:id",
"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 { request } from '../request';
import { clearUserRouteCache } from './route';
import type { ServiceRequestResult } from './shared';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId } from './shared';
/** 后端登录返回 */
interface BackendLoginToken {
@@ -14,10 +14,38 @@ interface BackendLoginToken {
interface BackendUserInfoDTO {
userId: string | number;
userName?: string | null;
nickname?: string | null;
roles?: 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;
/** 将后端 token 结构转换成前端现有结构 */
@@ -32,11 +60,48 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
return {
userId: String(data.userId ?? ''),
userName: data.userName ?? '',
nickname: data.nickname ?? '',
roles: data.roles ?? [],
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() {
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
*
* @param refreshToken 刷新 token
*/
export function fetchRefreshToken(refreshToken: string) {
return request<Api.Auth.LoginToken>({
export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<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`,
method: 'post',
data: {
refreshToken
}
params: { 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,19 +1,88 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult } from './shared';
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();
formData.append('file', file);
if (directory) {
formData.append('directory', directory);
}
return request<string>({
const result = await request<UploadFileResponse>({
url: `${FILE_PREFIX}/upload`,
method: 'post',
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,7 +1,9 @@
export * from './auth';
export * from './dict';
export * from './file';
export * from './infra';
export * from './object-context';
export * from './personal-item';
export * from './product';
export * from './project';
export * from './project-shared';

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

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();
}
/** 鑾峰彇浜у搧鍒嗛〉 */
/** 获取产品分页 */
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
const result = await request<ProductPageResponse>({
...safeJsonRequestConfig,
@@ -115,7 +115,7 @@ export function fetchGetProductOverviewSummary() {
});
}
/** 鑾峰彇浜у搧璇︽儏 */
/** 获取产品详情 */
export async function fetchGetProduct(id: string) {
const result = await request<ProductResponse>({
...safeJsonRequestConfig,
@@ -127,7 +127,7 @@ export async function fetchGetProduct(id: string) {
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
}
/** 鍒涘缓浜у搧 */
/** 新增产品 */
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
@@ -139,7 +139,19 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
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) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/update`,
@@ -148,7 +160,7 @@ export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
});
}
/** 鍙樻洿浜у搧鐘舵€? */
/** 改变产品状态 */
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/change-status`,
@@ -157,7 +169,7 @@ export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusPa
});
}
/** 鍒犻櫎浜у搧 */
/** 删除产品 */
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/delete`,
@@ -171,7 +183,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit<
Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
| 'id'
| 'parentId'
| 'moduleId'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'sourceBizId'
| 'attachments'
> & {
id: string | number;
parentId: string | number;
@@ -179,11 +198,68 @@ type RequirementResponse = Omit<
proposerId: string | number;
currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null;
implementProjectName?: string | null;
sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: 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 {
return {
@@ -194,11 +270,58 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
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) {
const result = await request<RequirementPageResponse>({
@@ -294,17 +417,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
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) {
const result = await request<Api.Product.RequirementLifecycleAction[]>({
@@ -317,16 +429,62 @@ export async function fetchGetRequirementAllowedTransitions(requirementId: strin
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
}
/** 获取需求生命周期信息 */
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleInfo>({
/** 批量获取需求可执行的状态动作列表 */
export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Product.RequirementBatchReqVO) {
const result = await request<Api.Product.RequirementAllowedTransitionBatchRespVO[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/lifecycle`,
method: 'get',
params: { requirementId, productId }
url: `${REQUIREMENT_PREFIX}/allowed-transitions/batch`,
method: 'post',
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 +498,41 @@ export async function fetchGetRequirementStatusDict() {
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
/** 获取需求终止态状态字典 */
export async function fetchGetRequirementTerminalStatusDict() {
const result = await request<Api.Product.RequirementStatusDict[]>({
/** 判断产品需求是否已指派并生成项目需求 */
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
method: 'get'
url: `${REQUIREMENT_PREFIX}/has-dispatched`,
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 ==========
@@ -475,6 +659,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
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) {
return request<boolean>({
...safeJsonRequestConfig,
@@ -484,6 +681,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(
id: string,
memberId: string,

View File

@@ -23,6 +23,8 @@ export type ProjectExecutionResponse = Omit<
| 'actualStartDate'
| 'actualEndDate'
| 'progressRate'
| 'priority'
| 'priorityName'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
@@ -34,16 +36,18 @@ export type ProjectExecutionResponse = Omit<
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null;
priority?: string | number | null;
priorityName?: string | null;
};
export type ExecutionMemberResponse = Omit<Api.Project.ExecutionMember, 'id' | 'executionId' | 'userId'> & {
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse;
executionId: StringIdResponse;
userId: StringIdResponse;
};
export type ExecutionMemberLogResponse = Omit<
Api.Project.ExecutionMemberLog,
export type ExecutionAssigneeLogResponse = Omit<
Api.Project.ExecutionAssigneeLog,
'id' | 'executionId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
@@ -52,6 +56,55 @@ export type ExecutionMemberLogResponse = Omit<
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<
Api.Project.ProjectTask,
| 'id'
@@ -59,24 +112,52 @@ export type ProjectTaskResponse = Omit<
| 'executionId'
| 'parentTaskId'
| 'ownerId'
| 'executionOwnerId'
| 'parentTaskOwnerId'
| 'availableActions'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'progressRate'
| 'assignees'
| 'attachments'
| 'priority'
| 'priorityName'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
executionId: StringIdResponse;
executionName?: string | null;
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
parentTaskId?: StringIdResponse | null;
ownerId: StringIdResponse;
executionOwnerId?: StringIdResponse | null;
parentTaskOwnerId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
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 {
@@ -172,12 +253,21 @@ 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);
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null,
@@ -189,12 +279,14 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
executionDesc: response.executionDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null
};
}
export function normalizeExecutionMember(response: ExecutionMemberResponse): Api.Project.ExecutionMember {
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return {
...response,
id: normalizeStringId(response.id),
@@ -207,7 +299,9 @@ export function normalizeExecutionMember(response: ExecutionMemberResponse): Api
};
}
export function normalizeExecutionMemberLog(response: ExecutionMemberLogResponse): Api.Project.ExecutionMemberLog {
export function normalizeExecutionAssigneeLog(
response: ExecutionAssigneeLogResponse
): Api.Project.ExecutionAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
@@ -226,9 +320,17 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId),
executionName: response.executionName ?? null,
executionStatusCode: response.executionStatusCode ?? null,
parentTaskId: normalizeNullableStringId(response.parentTaskId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null,
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
statusName: response.statusName ?? null,
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),
@@ -238,7 +340,58 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? 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
};
}

View File

@@ -8,19 +8,25 @@ import {
safeJsonRequestConfig
} from './shared';
import {
type ExecutionMemberLogResponse,
type ExecutionMemberResponse,
type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse,
type ProjectExecutionResponse,
type ProjectLocalDateValue,
type ProjectMemberResponse,
type ProjectTaskResponse,
type TaskAssigneeFromApiResponse,
type TaskAssigneeLogResponse,
type TaskWorklogResponse,
getProjectLifecycleActions,
normalizeExecutionMember,
normalizeExecutionMemberLog,
normalizeExecutionAssignee,
normalizeExecutionAssigneeLog,
normalizeProjectExecution,
normalizeProjectLocalDate,
normalizeProjectMember,
normalizeProjectTask
normalizeProjectTask,
normalizeTaskAssignee,
normalizeTaskAssigneeLog,
normalizeTaskWorklog
} from './project-shared';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
@@ -42,6 +48,16 @@ type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
type ProjectExecutionPageResponse = Api.Project.PageResult<ProjectExecutionResponse>;
type ProjectTaskPageResponse = Api.Project.PageResult<ProjectTaskResponse>;
type StatusBoardResponse = Api.Project.StatusBoard;
type ProjectTaskBoardPageResponse = {
items: Array<{
statusCode: string;
statusName: string;
sort: number;
terminal?: boolean;
list: ProjectTaskResponse[];
total: number;
}>;
};
type ProjectContextResponse = Omit<Api.Project.ProjectContext, 'currentProject' | 'navs'> & {
currentProject: Omit<Api.Project.ProjectContext['currentProject'], 'id'> & { id: string | number };
@@ -135,6 +151,18 @@ export async function fetchGetProject(id: string) {
return mapServiceResult(result as ServiceRequestResult<ProjectResponse>, normalizeProject);
}
/** 根据产品ID获取产品下的所有项目 */
export async function fetchGetProjectListByProductId(productId: string) {
const result = await request<ProjectResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/list-by-product`,
method: 'get',
params: { productId }
});
return mapServiceResult(result as ServiceRequestResult<ProjectResponse[]>, data => data.map(normalizeProject));
}
/** 创建项目 */
export async function fetchCreateProject(data: Api.Project.SaveProjectParams) {
const result = await request<string | number>({
@@ -147,6 +175,18 @@ export async function fetchCreateProject(data: Api.Project.SaveProjectParams) {
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 创建项目(含初始团队,原子接口) */
export async function fetchCreateProjectWithTeam(data: Api.Project.CreateProjectWithTeamParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/create-with-team`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新项目 */
export function fetchUpdateProject(data: Api.Project.UpdateProjectParams) {
return request<boolean>({
@@ -244,6 +284,28 @@ export function fetchInactiveProjectMember(
});
}
export async function fetchBatchCreateProjectMembers(id: string, data: Api.Project.BatchCreateProjectMembersParams) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/members/batch`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
Array.isArray(list) ? list.map(normalizeStringId) : []
);
}
export function fetchBatchInactiveProjectMembers(id: string, data: Api.Project.BatchInactiveProjectMembersParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/members/batch/inactive`,
method: 'post',
data
});
}
/** 获取项目设置 */
export async function fetchGetProjectSettings(id: string) {
const result = await fetchGetProject(id);
@@ -328,7 +390,7 @@ export async function fetchGetProjectExecution(projectId: string, executionId: s
}
/** 创建项目执行 */
export async function fetchCreateProjectExecution(projectId: string, data: Api.Project.SaveProjectExecutionParams) {
export async function fetchCreateProjectExecution(projectId: string, data: Api.Project.CreateProjectExecutionParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: getExecutionPrefix(projectId),
@@ -343,7 +405,7 @@ export async function fetchCreateProjectExecution(projectId: string, data: Api.P
export function fetchUpdateProjectExecution(
projectId: string,
executionId: string,
data: Api.Project.SaveProjectExecutionParams
data: Api.Project.UpdateProjectExecutionParams
) {
return request<boolean>({
...safeJsonRequestConfig,
@@ -367,6 +429,28 @@ export function fetchChangeProjectExecutionOwner(
});
}
/** 删除项目执行 */
export function fetchDeleteProjectExecution(
projectId: string,
executionId: string,
data: Api.Project.DeleteProjectExecutionParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}`,
method: 'delete',
data
});
}
/** 执行删除预检spec §2.1:返回是否含下挂数据,用于前端弹层分流) */
export function fetchPrecheckDeleteProjectExecution(projectId: string, executionId: string) {
return request<Api.Project.ProjectExecutionDeletePrecheck>({
url: `${getExecutionPrefix(projectId)}/${executionId}/delete-precheck`,
method: 'get'
});
}
/** 变更项目执行状态 */
export function fetchChangeProjectExecutionStatus(
projectId: string,
@@ -381,28 +465,28 @@ export function fetchChangeProjectExecutionStatus(
});
}
/** 获取项目执行成员 */
export async function fetchGetProjectExecutionMembers(projectId: string, executionId: string) {
const result = await request<ExecutionMemberResponse[]>({
/** 获取项目执行协办人 */
export async function fetchGetProjectExecutionAssignees(projectId: string, executionId: string) {
const result = await request<ExecutionAssigneeResponse[]>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ExecutionMemberResponse[]>, data =>
data.map(normalizeExecutionMember)
return mapServiceResult(result as ServiceRequestResult<ExecutionAssigneeResponse[]>, data =>
data.map(normalizeExecutionAssignee)
);
}
/** 创建项目执行成员 */
export async function fetchCreateProjectExecutionMember(
/** 创建项目执行协办人 */
export async function fetchCreateProjectExecutionAssignee(
projectId: string,
executionId: string,
data: Api.Project.CreateExecutionMemberParams
data: Api.Project.CreateExecutionAssigneeParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees`,
method: 'post',
data
});
@@ -410,37 +494,40 @@ export async function fetchCreateProjectExecutionMember(
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 移除项目执行成员 */
export function fetchInactiveProjectExecutionMember(
/** 移除项目执行协办人 */
export function fetchInactiveProjectExecutionAssignee(
projectId: string,
executionId: string,
payload: { memberId: string; data: Api.Project.InactiveExecutionMemberParams }
payload: { assigneeId: string; data: Api.Project.InactiveExecutionAssigneeParams }
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members/${payload.memberId}/inactive`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees/${payload.assigneeId}/inactive`,
method: 'post',
data: payload.data
});
}
/** 获取项目执行成员变更历史分页 */
export async function fetchGetProjectExecutionMemberLogPage(
/** 获取项目执行协办人变更历史分页 */
export async function fetchGetProjectExecutionAssigneeLogPage(
projectId: string,
executionId: string,
params?: Api.Project.ExecutionMemberLogSearchParams
params?: Api.Project.ExecutionAssigneeLogSearchParams
) {
const result = await request<Api.Project.PageResult<ExecutionMemberLogResponse>>({
const result = await request<Api.Project.PageResult<ExecutionAssigneeLogResponse>>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/member-logs`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignee-logs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Project.PageResult<ExecutionMemberLogResponse>>, data => ({
...data,
list: data.list.map(normalizeExecutionMemberLog)
}));
return mapServiceResult(
result as ServiceRequestResult<Api.Project.PageResult<ExecutionAssigneeLogResponse>>,
data => ({
...data,
list: data.list.map(normalizeExecutionAssigneeLog)
})
);
}
/** 获取项目任务分页 */
@@ -476,6 +563,32 @@ export function fetchGetProjectTaskStatusBoard(
});
}
/**
* 任务看板按状态分组的分页接口。
*
* 看板模式专用:一次请求拿到所有列(或指定列)的首屏 + 总数,替代"5 列 5 次 page"的旧方式。
* 列内向下滚续页时再传 `statusCode=[X]&pageNo=N+1` 单列查询。
*/
export async function fetchGetProjectTaskBoardPage(
projectId: string,
executionId: string,
params?: Api.Project.ProjectTaskBoardPageParams
) {
const result = await request<ProjectTaskBoardPageResponse>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/board-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskBoardPageResponse>, data => ({
items: data.items.map(item => ({
...item,
list: item.list.map(normalizeProjectTask)
}))
}));
}
/** 获取项目任务详情 */
export async function fetchGetProjectTask(projectId: string, executionId: string, taskId: string) {
const result = await request<ProjectTaskResponse>({
@@ -517,6 +630,30 @@ export function fetchUpdateProjectTask(
});
}
/** 删除项目任务 */
// eslint-disable-next-line max-params
export function fetchDeleteProjectTask(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.DeleteProjectTaskParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}`,
method: 'delete',
data
});
}
/** 任务删除预检spec §2.1 */
export function fetchPrecheckDeleteProjectTask(projectId: string, executionId: string, taskId: string) {
return request<Api.Project.ProjectTaskDeletePrecheck>({
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/delete-precheck`,
method: 'get'
});
}
/** 变更项目任务状态 */
export function fetchChangeProjectTaskStatus(
projectId: string,
@@ -530,3 +667,552 @@ export function fetchChangeProjectTaskStatus(
data: payload.data
});
}
// ============= 项目级跨执行任务(不带 executionId 路径段) =============
// 调试文档:所有接口挂在 /project/project/{projectId}/tasks/* 下;通过 involveUserId / ownerId / executionIds 等
// 入参组合表达"我的任务 / 项目全部 / 指定执行"等视角。原有执行级 {eid}/tasks/page 等保留不动。
function getProjectTasksPrefix(projectId: string) {
return `${PROJECT_PREFIX}/${projectId}/tasks`;
}
/** 项目级跨执行任务分页 */
export async function fetchGetProjectTaskPageCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossSearchParams
) {
const result = await request<ProjectTaskPageResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskPageResponse>, data => ({
...data,
list: data.list.map(normalizeProjectTask)
}));
}
/** 项目级跨执行任务状态看板 */
export function fetchGetProjectTaskStatusBoardCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossStatusBoardParams
) {
return request<StatusBoardResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/status-board`,
method: 'get',
params
});
}
/** 项目级跨执行任务看板分页(每列共用同一组 pageNo / pageSize列内固定 plannedEndDate ASC, id DESC */
export async function fetchGetProjectTaskBoardPageCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossBoardPageParams
) {
const result = await request<ProjectTaskBoardPageResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/board-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskBoardPageResponse>, data => ({
items: data.items.map(item => ({
...item,
list: item.list.map(normalizeProjectTask)
}))
}));
}
/**
* 项目级"今日小条"汇总4 个数字 + 服务器日期边界)。
*
* scope=all 必须有 project:task:query 权限,否则 403PROJECT_OBJECT_PERMISSION_DENIED
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403UI 应自动切回"我的"。
*/
export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) {
return request<Api.Project.ProjectTaskSummary>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/summary`,
method: 'get',
params
});
}
type TaskWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
return `${getTaskPrefix(projectId, executionId)}/${taskId}/worklogs`;
}
/** 获取任务工时分页 */
// eslint-disable-next-line max-params
export async function fetchGetProjectTaskWorklogPage(
projectId: string,
executionId: string,
taskId: string,
params?: Api.Project.TaskWorklogSearchParams
) {
const result = await request<TaskWorklogPageResponse>({
...safeJsonRequestConfig,
url: getWorklogPrefix(projectId, executionId, taskId),
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TaskWorklogPageResponse>, data => ({
...data,
list: data.list.map(normalizeTaskWorklog)
}));
}
/** 新增任务工时 */
// eslint-disable-next-line max-params
export async function fetchCreateProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.SaveTaskWorklogParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: getWorklogPrefix(projectId, executionId, taskId),
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 修改任务工时 */
// eslint-disable-next-line max-params
export function fetchUpdateProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getWorklogPrefix(projectId, executionId, taskId)}/${payload.worklogId}`,
method: 'put',
data: payload.data
});
}
/** 删除任务工时 */
// eslint-disable-next-line max-params
export function fetchDeleteProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
worklogId: string
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getWorklogPrefix(projectId, executionId, taskId)}/${worklogId}`,
method: 'delete'
});
}
/** 5.6 获取任务协办人列表(仅当前活跃) */
export async function fetchGetProjectTaskAssignees(projectId: string, executionId: string, taskId: string) {
const result = await request<TaskAssigneeFromApiResponse[]>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TaskAssigneeFromApiResponse[]>, data =>
data.map(normalizeTaskAssignee)
);
}
/** 5.7 加入任务协办人 */
// eslint-disable-next-line max-params
export async function fetchCreateProjectTaskAssignee(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.CreateTaskAssigneeParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 5.8 退出任务协办人 */
// eslint-disable-next-line max-params
export function fetchInactiveProjectTaskAssignee(
projectId: string,
executionId: string,
taskId: string,
assigneeId: string,
data: Api.Project.InactiveTaskAssigneeParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees/${assigneeId}/inactive`,
method: 'post',
data
});
}
/** 5.9 任务协办人变更历史分页 */
// eslint-disable-next-line max-params
export async function fetchGetProjectTaskAssigneeLogPage(
projectId: string,
executionId: string,
taskId: string,
params?: Api.Project.TaskAssigneeLogSearchParams
) {
const result = await request<Api.Project.PageResult<TaskAssigneeLogResponse>>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignee-logs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Project.PageResult<TaskAssigneeLogResponse>>, data => ({
...data,
list: data.list.map(normalizeTaskAssigneeLog)
}));
}
// ========== 项目需求 API ==========
const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requirement`;
type ProjectRequirementResponse = Omit<
Api.Project.ProjectRequirement,
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments'
> & {
id: string | number;
projectId: string | number;
parentId: string | number;
moduleId: string | number;
proposerId: string | number;
currentHandlerUserId?: string | number | null;
sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: ProjectRequirementResponse[];
};
type ProjectRequirementPageResponse = Api.Project.PageResult<ProjectRequirementResponse>;
type ProjectRequirementReviewResponse = Omit<
Api.Project.ProjectRequirementReview,
'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 ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
id: string | number;
parentId: string | number;
projectId: string | number;
children?: ProjectRequirementModuleResponse[];
};
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 normalizeProjectRequirement(requirement: ProjectRequirementResponse): Api.Project.ProjectRequirement {
return {
...requirement,
id: normalizeStringId(requirement.id),
projectId: normalizeStringId(requirement.projectId),
parentId: normalizeStringId(requirement.parentId),
moduleId: normalizeStringId(requirement.moduleId),
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
children: requirement.children?.map(normalizeProjectRequirement)
};
}
function normalizeProjectRequirementReview(
review: ProjectRequirementReviewResponse
): Api.Project.ProjectRequirementReview {
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 normalizeProjectRequirementModule(
module: ProjectRequirementModuleResponse
): Api.Project.ProjectRequirementModule {
return {
...module,
id: normalizeStringId(module.id),
parentId: normalizeStringId(module.parentId),
projectId: normalizeStringId(module.projectId),
children: module.children?.map(normalizeProjectRequirementModule)
};
}
/** 获取项目需求分页列表 */
export async function fetchGetProjectRequirementPage(params?: Api.Project.ProjectRequirementSearchParams) {
const result = await request<ProjectRequirementPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementPageResponse>, data => ({
...data,
list: data.list.map(normalizeProjectRequirement)
}));
}
/** 获取项目需求树形列表 */
export async function fetchGetProjectRequirementTree(params?: Api.Project.ProjectRequirementSearchParams) {
const result = await request<ProjectRequirementPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/tree`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementPageResponse>, data => ({
...data,
list: data.list.map(normalizeProjectRequirement)
}));
}
/** 获取项目需求详情 */
export async function fetchGetProjectRequirement(id: string, projectId: string) {
const result = await request<ProjectRequirementResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/get`,
method: 'get',
params: { id, projectId }
});
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementResponse>, normalizeProjectRequirement);
}
/** 创建项目需求 */
export async function fetchCreateProjectRequirement(data: Api.Project.SaveProjectRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新项目需求 */
export function fetchUpdateProjectRequirement(data: Api.Project.UpdateProjectRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/update`,
method: 'put',
data
});
}
/** 变更项目需求状态 */
export function fetchChangeProjectRequirementStatus(data: Api.Project.ChangeProjectRequirementStatusParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/change-status`,
method: 'post',
data
});
}
/** 删除项目需求 */
export function fetchDeleteProjectRequirement(data: Api.Project.DeleteProjectRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/delete`,
method: 'post',
data
});
}
/** 拆分项目需求 */
export async function fetchSplitProjectRequirement(data: Api.Project.SplitProjectRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/split`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 关闭项目需求 */
export function fetchCloseProjectRequirement(data: Api.Project.CloseProjectRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/close`,
method: 'post',
data
});
}
/** 获取项目需求可执行状态动作列表 */
export async function fetchGetProjectRequirementAllowedTransitions(requirementId: string, projectId: string) {
const result = await request<Api.Project.ProjectRequirementLifecycleAction[]>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/allowed-transitions`,
method: 'get',
params: { requirementId, projectId }
});
return mapServiceResult(
result as ServiceRequestResult<Api.Project.ProjectRequirementLifecycleAction[]>,
data => data
);
}
/** 批量获取项目需求可执行状态动作列表 */
export async function fetchGetProjectRequirementAllowedTransitionsBatch(
data: Api.Project.ProjectRequirementBatchReqVO
) {
const result = await request<Api.Project.ProjectRequirementAllowedTransitionBatchRespVO[]>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/allowed-transitions/batch`,
method: 'post',
data
});
return mapServiceResult(
result as ServiceRequestResult<Api.Project.ProjectRequirementAllowedTransitionBatchRespVO[]>,
data1 =>
data1.map(item => ({
requirementId: normalizeStringId(item.requirementId),
transitions: item.transitions
}))
);
}
/** 提交项目需求评审 */
export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/review/submit`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 获取项目需求评审记录 */
export async function fetchGetProjectRequirementReview(projectId: string, requirementId: string) {
const result = await request<ProjectRequirementReviewResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/review/get`,
method: 'get',
params: { projectId, requirementId }
});
return mapServiceResult(
result as ServiceRequestResult<ProjectRequirementReviewResponse>,
normalizeProjectRequirementReview
);
}
/** 获取项目需求状态字典 */
export async function fetchGetProjectRequirementStatusDict() {
const result = await request<Api.Project.ProjectRequirementStatusDict[]>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
}
/** 获取项目需求模块树 */
export async function fetchGetProjectRequirementModuleTree(projectId: string) {
const result = await request<ProjectRequirementModuleResponse[]>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/module/tree`,
method: 'get',
params: { projectId }
});
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementModuleResponse[]>, data =>
data.map(normalizeProjectRequirementModule)
);
}
/** 创建项目需求模块 */
export async function fetchCreateProjectRequirementModule(data: Api.Project.SaveProjectRequirementModuleParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/module/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新项目需求模块 */
export function fetchUpdateProjectRequirementModule(data: Api.Project.SaveProjectRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/module/update`,
method: 'put',
data
});
}
/** 删除项目需求模块 */
export function fetchDeleteProjectRequirementModule(data: Api.Project.DeleteProjectRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_REQUIREMENT_PREFIX}/module/delete`,
method: 'post',
data
});
}

View File

@@ -1,4 +1,3 @@
import type { RouteMeta } from 'vue-router';
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
import { objectContextDomainConfigs } from '@/constants/object-context';
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[]>({
...safeJsonRequestConfig,
url: `${USER_PREFIX}/simple-list`,
@@ -669,7 +669,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
* - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级
*/
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
@@ -686,7 +686,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
* 通过搜索框的查询条件,获取用户管理链路树形结构
* 用于树形控件展示,包含用户的上下级层级关系
*/
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
@@ -706,7 +706,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
*
* @param id 关系记录主键 ID
*/
export function fetchGetUserManagementRelation(id: string) {
export async function fetchGetUserManagementRelation(id: string) {
return request<UserManagementRelationResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
@@ -724,7 +724,7 @@ export function fetchGetUserManagementRelation(id: string) {
*
* @param data 创建请求参数
*/
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
return request<string | number>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
@@ -778,3 +778,20 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
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,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,126 +5,153 @@ import { localStg } from '@/utils/storage';
import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales';
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';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
}
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
export const request = withDedupe(
createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
}
},
async onRequest(config) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
applyApiEncrypt(config);
return config;
},
isBackendSuccess(response) {
// 当后端返回码为 "0"(默认)时,表示请求成功
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
},
async onBackendFail(response, instance) {
const authStore = useAuthStore();
const responseCode = String(response.data.code);
function handleLogout() {
authStore.resetStore();
}
function logoutAndCleanup() {
handleLogout();
window.removeEventListener('beforeunload', handleLogout);
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
}
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
if (logoutCodes.includes(responseCode)) {
handleLogout();
return null;
}
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
// 防止用户刷新页面绕过退出逻辑
window.addEventListener('beforeunload', handleLogout);
window.$messageBox
?.confirm(response.data.msg, $t('common.error'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'error',
closeOnClickModal: false,
closeOnPressEscape: false
})
.then(() => {
logoutAndCleanup();
});
return null;
}
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(responseCode)) {
const success = await handleExpiredRequest(request.state);
if (success) {
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) {
// skipAuth 为 true 的请求不注入 Authorization——避免给公开接口如 refresh-token
// 带上过期 access 头被网关拦截(网关只看 Authorization不区分路由是否 PermitAll
if (!config.skipAuth) {
const Authorization = getAuthorization();
Object.assign(response.config.headers, { Authorization });
return instance.request(response.config) as Promise<AxiosResponse>;
Object.assign(config.headers, { Authorization });
}
applyApiEncrypt(config);
return config;
},
isBackendSuccess(response) {
// 当后端返回码为 "0"(默认)时,表示请求成功
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
},
async onBackendFail(response, instance) {
const authStore = useAuthStore();
const responseCode = String(response.data.code);
if (
shouldDeferBackendFailToCaller({
suppressErrorMessage: response.config.suppressErrorMessage,
skipTokenRefresh: response.config.skipTokenRefresh
})
) {
return null;
}
function handleLogout() {
authStore.resetStore();
}
function logoutAndCleanup() {
handleLogout();
window.removeEventListener('beforeunload', handleLogout);
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
}
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
// 走 notifySessionExpired 而不是裸 resetStore保证并发请求只弹一次 toast、只清一次状态
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
if (logoutCodes.includes(responseCode)) {
notifySessionExpired();
return null;
}
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
// 防止用户刷新页面绕过退出逻辑
window.addEventListener('beforeunload', handleLogout);
window.$messageBox
?.confirm(response.data.msg, $t('common.error'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'error',
closeOnClickModal: false,
closeOnPressEscape: false
})
.then(() => {
logoutAndCleanup();
});
return null;
}
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
if (expiredTokenCodes.includes(responseCode)) {
if (response.config.skipTokenRefresh) {
notifySessionExpired();
return null;
}
const success = await handleExpiredRequest(request.state);
if (success) {
const Authorization = getAuthorization();
Object.assign(response.config.headers, { Authorization });
return instance.request(response.config) as Promise<AxiosResponse>;
}
}
return null;
},
onError(error) {
// 请求失败时,在这里统一处理错误提示
let message = error.message;
let backendErrorCode = '';
// 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message;
backendErrorCode = String(error.response?.data?.code || '');
}
const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
if (
shouldSuppressErrorMessage({
backendErrorCode,
suppressErrorMessage,
logoutCodes,
modalLogoutCodes,
expiredTokenCodes
})
) {
return;
}
showErrorMsg(request.state, message);
}
return null;
},
onError(error) {
// 请求失败时,在这里统一处理错误提示
let message = error.message;
let backendErrorCode = '';
// 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message;
backendErrorCode = String(error.response?.data?.code || '');
}
// 这类错误信息已经通过弹窗展示,不再重复提示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(backendErrorCode)) {
return;
}
// token 过期时会自动刷新并重试请求,这里无需额外提示
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(backendErrorCode)) {
return;
}
showErrorMsg(request.state, message);
}
}
)
);
export const demoRequest = createRequest(

View File

@@ -1,6 +1,7 @@
import { useAuthStore } from '@/store/modules/auth';
import { localStg } from '@/utils/storage';
import { fetchRefreshToken } from '../api';
import { SESSION_EXPIRED_MESSAGE } from './error-message';
import type { RequestInstanceState } from './type';
export function getAuthorization() {
@@ -12,8 +13,6 @@ export function getAuthorization() {
/** 刷新 token */
async function handleRefreshToken() {
const { resetStore } = useAuthStore();
const rToken = localStg.get('refreshToken') || '';
const { error, data } = await fetchRefreshToken(rToken);
if (!error) {
@@ -22,25 +21,48 @@ async function handleRefreshToken() {
return true;
}
resetStore();
notifySessionExpired();
return false;
}
export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) {
state.refreshTokenFn = handleRefreshToken();
if (!state.refreshTokenPromise) {
state.refreshTokenPromise = handleRefreshToken();
}
const success = await state.refreshTokenFn;
const success = await state.refreshTokenPromise;
setTimeout(() => {
state.refreshTokenFn = null;
state.refreshTokenPromise = null;
}, 1000);
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) {
if (!state.errMsgStack?.length) {
state.errMsgStack = [];

View File

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

View File

@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
import { defineStore } from 'pinia';
import { useLoading } from '@sa/hooks';
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
import { resetSessionExpiredFlag } from '@/service/request/shared';
import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum';
@@ -28,6 +29,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const userInfo: Api.Auth.UserInfo = reactive({
userId: '',
userName: '',
nickname: '',
roles: [],
buttons: []
});
@@ -49,16 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
clearAuthStorage();
authStore.$reset();
dictStore.resetDictCache();
objectContextStore.$reset();
// setup store 没有内置 $reset需要显式重置内部状态避免 token / userInfo 残留导致 isLogin 误判。
token.value = '';
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();
}
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 */
@@ -148,6 +161,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
token.value = loginToken.token;
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
resetSessionExpiredFlag();
return true;
}
@@ -167,6 +183,18 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
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() {
const hasToken = getToken();
@@ -189,6 +217,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
loginLoading,
resetStore,
login,
initUserInfo
initUserInfo,
refreshUserInfo
};
});

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
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';
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'));
}
// 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(
dictType: string,
list: Api.Dict.FrontendDictData[],
@@ -31,13 +40,25 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType,
sort: item.sort,
status: item.status ?? 0,
remark: null,
colorType: normalizeColorType(item.colorType),
remark: item.remark ?? null,
createTime: 0
}));
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) {
const entries = Object.entries(cache);
@@ -89,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
const loadedAt = ref<number | null>(null);
let initPromise: Promise<boolean> | null = null;
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
function resetDictCache() {
dictTypes.value = [];
@@ -96,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
loadedAt.value = null;
initialized.value = false;
initPromise = null;
dictDataLoadPromises.clear();
}
async function initDictCache(force = false) {
@@ -137,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
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) {
if (!dictType) {
return [];
@@ -199,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
dictDataMap,
loadedAt,
initDictCache,
ensureDictData,
resetDictCache,
getDictData,
getDictOptions,

View File

@@ -149,9 +149,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 重置 store */
async function resetStore() {
const routeStore = useRouteStore();
routeStore.$reset();
// setup store 没有内置 $reset需要显式重置内部状态。
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true导致下面 initConstantRoute 早返,
// 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
setIsInitConstantRoute(false);
setIsInitAuthRoute(false);
constantRoutes.value = [];
authRoutes.value = [];
menus.value = [];
cacheRoutes.value = [];
excludeCacheRoutes.value = [];
resetVueRoutes();
@@ -242,7 +249,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 统一处理常量路由和权限路由 */
async function handleConstantAndAuthRoutes() {
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);

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

@@ -416,6 +416,20 @@ html .el-collapse {
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 {
display: flex;
flex-direction: column;
@@ -428,6 +442,19 @@ html .el-collapse {
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 {
display: flex;
height: calc(100% - 56px);

View File

@@ -13,8 +13,43 @@ declare namespace Api {
interface UserInfo {
userId: string;
userName: string;
nickname: string;
roles: 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;
/** status: 0 enabled, 1 disabled */
status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** remark */
remark?: string | null;
/** create time */
@@ -73,6 +75,10 @@ declare namespace Api {
dictType?: string;
/** status: 0 enabled, 1 disabled */
status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** 备注,可用于下拉中文释义展示 */
remark?: string | null;
}
/** frontend runtime dict cache map */

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

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

@@ -210,6 +210,32 @@ declare namespace Api {
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 {
roleId: string;
remark?: string | null;
@@ -222,18 +248,37 @@ declare namespace Api {
reason?: string | null;
}
interface BatchInactiveProductMembersParams {
memberIds: string[];
reason?: string | null;
}
// ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */
type RequirementStatusCode =
| 'pending_confirm'
| 'pending_claim'
| 'pending_review'
| 'pending_dispatch'
| 'reviewed'
| 'review_rejected'
| 'implementing'
| 'accepted'
| 'closed'
| 'rejected'
| 'cancelled';
/** 需求状态动作编码 */
type RequirementStatusActionCode =
| 'claim_to_review'
| 'claim_to_dispatch'
| 'pass_review'
| 'reject_review'
| 'dispatch'
| 'cancel'
| 'accept'
| 'close'
| 'reject';
/** 需求来源类型 */
type RequirementSourceType = 'manual' | 'work_order';
@@ -256,17 +301,19 @@ declare namespace Api {
moduleId: string;
/** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired;
/** 需求标题 */
/** 需求名称 */
title: string;
/** 需求描述(富文本) */
/** 需求内容(富文本) */
description?: string | null;
/** 需求分类字典值 */
/** 附件列表 */
attachments?: Api.Project.AttachmentItem[] | null;
/** 需求类型字典值 */
category: string;
/** 需求类名称 */
/** 需求类名称 */
categoryName?: string | null;
/** 来源类型 */
/** 需求来源类型 */
sourceType: RequirementSourceType;
/** 来源业务ID */
/** 需求来源业务ID */
sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority;
@@ -286,12 +333,12 @@ declare namespace Api {
currentHandlerUserId?: string | null;
/** 当前处理人姓名 */
currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */
/** 默认关联项目编号 */
implementProjectId?: string | null;
/** 实现项目名称 */
/** 默认关联项目名称 */
implementProjectName?: string | null;
/** 预期完成时间 */
completionDate: string;
/** 预期完成日期 */
expectedTime?: string | null;
/** 排序值 */
sort: number;
/** 创建时间 */
@@ -300,8 +347,6 @@ declare namespace Api {
updateTime: string;
/** 子需求列表(树形结构) */
children?: Requirement[];
/** 是否为终态 */
terminal?: boolean;
}
// ========== 需求模块实体 ==========
@@ -338,25 +383,103 @@ declare namespace Api {
initialFlag: boolean;
/** 是否终态 */
terminalFlag: boolean;
/** 是否允许编辑 */
allowEdit: boolean;
}
// ========== 需求生命周期 ==========
interface RequirementLifecycleAction {
actionCode: string;
actionCode: RequirementStatusActionCode;
actionName: string;
toStatusCode: string;
toStatusName: string;
needReason: boolean;
}
interface RequirementLifecycleInfo {
statusCode: RequirementStatusCode;
statusName?: string | null;
lastStatusReason?: string | null;
terminal: boolean;
allowEdit: boolean;
availableActions: RequirementLifecycleAction[];
interface RequirementBatchReqVO {
productId: string;
requirementIds: string[];
}
interface RequirementAllowedTransitionBatchRespVO {
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 +504,15 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'implementProjectId'
| 'completionDate'
| 'expectedTime'
| 'sort'
>;
@@ -418,11 +544,14 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'completionDate'
| 'currentHandlerUserNickname'
| 'expectedTime'
| 'sort'
>;

View File

@@ -65,13 +65,13 @@ declare namespace Api {
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
/** 执行动作编码 */
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel' | 'complete';
/** 任务状态编码 */
type ProjectTaskStatusCode = 'pending' | 'active' | 'blocked' | 'completed' | 'cancelled';
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
/** 任务动作编码 */
type ProjectTaskActionCode = 'start' | 'block' | 'resume' | 'complete' | 'cancel';
type ProjectTaskActionCode = 'auto_start' | 'pause' | 'resume' | 'complete' | 'cancel';
interface LifecycleAction<ActionCode extends string = string> {
actionCode: ActionCode;
@@ -96,6 +96,10 @@ declare namespace Api {
id: string;
projectId: string;
projectRequirementId: string | null;
/** 关联项目需求名称service 层批量回填;未关联 = null */
projectRequirementName: string | null;
/** 关联项目需求状态编码pending_confirm/pending_review/implementing/accepted/closed/rejected/cancelled */
projectRequirementStatusCode: string | null;
executionName: string;
executionType: string | null;
ownerId: string;
@@ -110,13 +114,17 @@ declare namespace Api {
actualStartDate: string | null;
actualEndDate: string | null;
progressRate: number;
/** 优先级字典 valuerdms_req_priority"0" P0 / "1" P1默认/ "2" P2 / "3" P3数字越小越高 */
priority: string;
/** 优先级标签预留字段;当前后端不填、永远为 null前端按 priority 自译 */
priorityName: string | null;
executionDesc: string | null;
lastStatusReason: string | null;
createTime: string;
updateTime: string;
}
interface ExecutionMember {
interface ExecutionAssignee {
id: string;
executionId: string;
userId: string;
@@ -126,14 +134,14 @@ declare namespace Api {
removedReason: string | null;
}
/** 执行成员变更事件类型 */
type ExecutionMemberActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
/** 执行协办人变更事件类型 */
type ExecutionAssigneeActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
/** 执行成员变更历史 */
interface ExecutionMemberLog {
/** 执行协办人变更历史 */
interface ExecutionAssigneeLog {
id: string;
executionId: string;
actionType: ExecutionMemberActionType;
actionType: ExecutionAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
@@ -142,23 +150,95 @@ declare namespace Api {
reason: string | null;
}
type ExecutionMemberLogSearchParams = CommonType.RecordNullable<
type ExecutionAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: ExecutionMemberActionType[];
actionTypes: ExecutionAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 通用附件元数据(任务 / 工时等域共用,规则见 AttachmentValidator */
interface AttachmentItem {
/**
* 文件 IDinfra_file.id 字符串形式)。
* 用于会话级清理时调用 DELETE /system/file/delete?id=xxx 删除孤儿文件。
*/
fileId: string;
url: string;
name: string;
size?: number;
contentType?: string;
}
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
interface TaskAssigneeRef {
id: string;
userId: string;
nickname: string;
/** 加入时间5.6 路径返5.3 嵌入路径不返,留 undefined */
joinedAt?: string | null;
}
/** 协办人变更事件类型5.9 actionType */
type TaskAssigneeActionType = 'join' | 'inactive';
/** 协办人变更日志 */
interface TaskAssigneeLog {
id: string;
taskId: string;
actionType: TaskAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
operatorNicknameSnapshot: string | null;
actionTime: string;
reason: string | null;
}
type TaskAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: TaskAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 5.7 加入协办人入参 */
interface CreateTaskAssigneeParams {
userId: string;
}
/** 5.8 退出协办人入参 */
interface InactiveTaskAssigneeParams {
reason: string;
}
interface ProjectTask {
id: string;
projectId: string;
executionId: string;
/** 所属执行名称;跨执行查询必有,单执行查询可缺省 */
executionName?: string | null;
/** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */
executionStatusCode?: ProjectExecutionStatusCode | null;
parentTaskId: string | null;
/** 所属执行关联的项目需求 ID透传未关联 = null */
projectRequirementId: string | null;
/** 所属执行关联的项目需求名称(透传,未关联 = null跨执行查询永远为 null前端不在跨执行视角展示 */
projectRequirementName: string | null;
/** 所属执行关联的项目需求状态编码(同上) */
projectRequirementStatusCode: string | null;
taskTitle: string;
type: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用跨执行查询永远为 null按钮判定退化为只看权限码 */
executionOwnerId: string | null;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: string | null;
statusCode: ProjectTaskStatusCode;
statusName: string | null;
terminal: boolean;
@@ -169,18 +249,45 @@ declare namespace Api {
plannedEndDate: string | null;
actualStartDate: string | null;
actualEndDate: string | null;
/** 优先级字典 valuerdms_req_priority"0" P0 / "1" P1默认/ "2" P2 / "3" P3数字越小越高 */
priority: string;
/** 优先级标签预留字段;当前后端不填、永远为 null前端按 priority 自译 */
priorityName: string | null;
taskDesc: string | null;
lastStatusReason: string | null;
assignees?: TaskAssigneeRef[] | null;
attachments?: AttachmentItem[] | null;
/** 已填报工时合计单位小时0.5 颗粒BigDecimal。逻辑删除的工时不计入。 */
totalSpentHours?: number | null;
createTime: string;
updateTime: string;
}
/**
* 执行截止时间范围(基于 plannedEndDateoverdue 逾期 / today 今天到期 / thisWeek 本周到期。
* 与任务侧 dueRange 同口径,后端三档均排除终态执行(已完成 / 已取消);未知值 = 不过滤。
*/
type ProjectExecutionDueRange = 'overdue' | 'today' | 'thisWeek';
/**
* 项目执行分页入参(`GET /project/project/{projectId}/executions/page`)。
*
* - `involveUserId` / `ownerId` 互斥:同传后端不报错但语义变 AND前端切视角时务必清另一字段。
* - 不传 `involveUserId` 且不传 `ownerId` = 项目下全部执行。
* - `dueRange` 按计划结束日期过滤,与其它参数 AND详见 ProjectExecutionDueRange。
*/
type ProjectExecutionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
executionType: string;
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
statusCode: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
dueRange: ProjectExecutionDueRange;
updateTime: string[];
}
>;
@@ -188,19 +295,42 @@ declare namespace Api {
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
keyword: string;
executionType: string;
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
/** 截止时间范围过滤,传入后各状态分组计数均在该范围内统计(口径同 page */
dueRange: ProjectExecutionDueRange;
updateTime: string[];
}>;
interface SaveProjectExecutionParams {
/** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams {
executionName: string;
executionType: string;
ownerId: string;
projectRequirementId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
executionDesc: string | null;
assigneeUserIds?: string[];
}
/** 执行创建/编辑弹层 emit 的统一 payload创建时含 ownerId + assigneeUserIds编辑时不含 */
type SaveProjectExecutionParams = CreateProjectExecutionParams;
/** 编辑执行入参(不含 ownerId / assigneeUserIds */
interface UpdateProjectExecutionParams {
executionName: string;
executionType: string;
projectRequirementId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
executionDesc: string | null;
memberUserIds?: string[];
}
interface ChangeExecutionOwnerParams {
@@ -213,11 +343,11 @@ declare namespace Api {
reason: string | null;
}
interface CreateExecutionMemberParams {
interface CreateExecutionAssigneeParams {
userId: string;
}
interface InactiveExecutionMemberParams {
interface InactiveExecutionAssigneeParams {
reason: string;
}
@@ -227,6 +357,8 @@ declare namespace Api {
parentTaskId: string;
ownerId: string;
statusCode: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
updateTime: string[];
}
>;
@@ -238,16 +370,130 @@ declare namespace Api {
updateTime: string[];
}>;
/**
* 任务看板按状态分组的分页入参。
*
* - `statusCode` 缺省 → 返回该执行下任务状态字典中的全部状态(即使该状态下当前没有任务,也要回该列、`total=0`、`list=[]`)。
* - 传入数组 → 只返回这些状态的列。
* - `pageNo` / `pageSize` 应用到所有返回的状态(同一页码下各状态各自分页),前端不需要"每列独立 pageNo"。
*/
type ProjectTaskBoardPageParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
statusCode: string[];
keyword: string;
parentTaskId: string;
ownerId: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
updateTime: string[];
}
>;
interface ProjectTaskBoardColumn {
statusCode: string;
statusName: string;
sort: number;
terminal?: boolean;
list: ProjectTask[];
total: number;
}
interface ProjectTaskBoardPage {
items: ProjectTaskBoardColumn[];
}
/** 截止时间快速选项(跨执行接口专属) */
type ProjectTaskDueRange = 'overdue' | 'today' | 'thisWeek';
/** 跨执行任务排序字段 */
type ProjectTaskCrossSortBy = 'plannedEndDate' | 'priority' | 'updateTime' | 'createTime';
type ProjectTaskCrossSortOrder = 'asc' | 'desc';
/**
* 项目级跨执行任务分页入参(`GET /project/project/{projectId}/tasks/page`)。
*
* - `involveUserId` / `ownerId` 互斥:同传只 `ownerId` 生效(后端 SQL 双重过滤)。
* - `executionIds` 不传 = 项目内全部执行;空数组 `[]` = 明确返空。
* - `executionInvolveUserId` = 限定到"该用户参与的执行"owner 或活跃执行协办);未参与任何执行时返空;
* 与 `executionIds` 同传为 AND。用它表达"我参与的执行"范围,无需前端先查执行 id 再回传。
* - `executionStatusCodes` 在任务可见性之上叠加"任务所属执行状态 ∈ 白名单"过滤;多值 OR
* 与 `executionIds` 同传时为 AND。详见 `docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html`。
* - 不传 `involveUserId / ownerId` 且无 `project:task:query` 权限时,后端静默降级为"自己有身份的范围",不抛 403。
*/
type ProjectTaskCrossSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
executionIds: string[];
/**
* 执行成员过滤:该用户作为执行 owner 或活跃执行协办人的执行 → 其下任务;未参与任何执行时返空。
* 与 `involveUserId`(任务成员)正交,可同传取交集。
*/
executionInvolveUserId: string;
/** 任务所属执行的状态白名单(用于左侧执行池按状态 chip 切换时的任务范围过滤) */
executionStatusCodes: ProjectExecutionStatusCode[];
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
statusCodes: ProjectTaskStatusCode[];
/** 优先级字典 value"0"~"3" */
priority: string;
parentTaskId: string;
dueRange: ProjectTaskDueRange;
/** 更新时间范围 [start, end],格式 yyyy-MM-dd HH:mm:ss */
updateTime: string[];
sortBy: ProjectTaskCrossSortBy;
sortOrder: ProjectTaskCrossSortOrder;
}
>;
/** 项目级跨执行任务状态看板入参(与 page 同口径但不含 pageNo/pageSize/statusCodes/sortBy/sortOrder */
type ProjectTaskCrossStatusBoardParams = Omit<
ProjectTaskCrossSearchParams,
'pageNo' | 'pageSize' | 'statusCodes' | 'sortBy' | 'sortOrder'
>;
/** 项目级跨执行任务看板分页入参 */
type ProjectTaskCrossBoardPageParams = Omit<ProjectTaskCrossSearchParams, 'sortBy' | 'sortOrder'>;
/** 项目级"今日小条"汇总入参 */
interface ProjectTaskSummaryParams {
/** 默认 mine不传也走 mineall 必须有 project:task:query 权限,否则 403 */
scope?: 'mine' | 'all';
}
/**
* 项目级"今日小条"汇总响应(`GET /project/project/{projectId}/tasks/summary`)。
*
* 数字一致性dueThisWeek 的范围与 page?dueRange=thisWeek 完全一致(本周一~本周日)。
* today / weekStart / weekEnd 直接展示,不要前端再算"今天/本周一"(服务器时区为 Asia/Shanghai
*/
interface ProjectTaskSummary {
overdue: number;
dueToday: number;
dueThisWeek: number;
doneThisWeek: number;
today: string;
weekStart: string;
weekEnd: string;
}
interface SaveProjectTaskParams {
parentTaskId: string | null;
taskTitle: string;
type: string;
ownerId: string | null;
progressRate?: number;
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
taskDesc: string | null;
/** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行成员且不能等于 ownerId */
/** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行协办人且不能等于 ownerId */
assigneeUserIds?: string[];
/** 编辑语义null 保留原值 / [] 清空 / [...] 整体替换 */
attachments?: AttachmentItem[] | null;
}
interface ChangeTaskStatusParams {
@@ -255,6 +501,56 @@ declare namespace Api {
reason: string | null;
}
/** 任务工时记录 */
interface TaskWorklog {
id: string;
taskId: string;
userId: string;
userNickname: string | null;
/** 段起始日期YYYY-MM-DD单天=与 endDate 相等 */
startDate: string;
/** 段结束日期YYYY-MM-DD单天=与 startDate 相等 */
endDate: string;
/** 本次填报小时数BigDecimal0.5 颗粒,> 0 */
durationHours: number;
/** 本次填报进度0~100scale=2 */
progressRate: number;
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
difficulty: string;
/** 后端预留字段,目前始终为 null前端按 difficulty + 字典 cache 自译 */
difficultyName?: string | null;
workContent: string | null;
attachments?: AttachmentItem[] | null;
createTime: string;
updateTime: string;
}
type TaskWorklogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
userId: string;
startDate: string;
endDate: string;
/** 完成难度筛选,等值匹配;不传 = 全部 */
difficulty: string;
}
>;
interface SaveTaskWorklogParams {
/** 段起始日期YYYY-MM-DD */
startDate: string;
/** 段结束日期YYYY-MM-DD不得早于 startDate */
endDate: string;
/** 本次填报小时数,> 0 且 0.5 整数倍 */
durationHours: number;
/** 本次填报进度0~100scale=2必填 */
progressRate: number;
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
difficulty: string;
workContent?: string | null;
/** 编辑语义null 保留原值 / [] 清空 / [...] 替换 */
attachments?: AttachmentItem[] | null;
}
/** 项目设置参数 */
interface ProjectSettings {
baseInfo: ProjectSettingBaseInfo;
@@ -424,6 +720,44 @@ declare namespace Api {
reason: string;
}
/** 删除执行入参 */
interface DeleteProjectExecutionParams {
/** 二次确认:必须与当前执行名称完全一致 */
executionName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
confirmText: string;
/** 删除原因,写入审计日志 */
reason: string;
}
/** 删除任务入参 */
interface DeleteProjectTaskParams {
/** 二次确认:必须与当前任务名称完全一致 */
taskName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
confirmText: string;
/** 删除原因,写入审计日志 */
reason: string;
}
/** 执行删除预检spec §2.1:判断是否需要走重型确认弹层) */
interface ProjectExecutionDeletePrecheck {
/** 该执行下任务总数(含子孙,含 completed展示用 */
taskCount: number;
/** taskCount > 0 视为 true */
hasDependentData: boolean;
}
/** 任务删除预检spec §2.1 */
interface ProjectTaskDeletePrecheck {
/** 直接子任务数 */
childTaskCount: number;
/** 工作日志条数 */
worklogCount: number;
/** childTaskCount + worklogCount > 0 视为 true */
hasDependentData: boolean;
}
/** 创建项目成员参数 */
interface CreateProjectMemberParams {
userId: string;
@@ -446,5 +780,306 @@ declare namespace Api {
interface InactiveProjectMemberParams {
reason: string | null;
}
/**
* 批量新增项目成员参数
*
* 刻意不复用 CreateProjectMemberParams批量接口不承担"项目负责人交接"语义,
* 后端兜底拒绝 roleId 为项目负责人角色的项。
*/
interface BatchCreateProjectMembersParams {
members: Array<{
userId: string;
roleId: string;
remark?: string | null;
}>;
}
/** 批量移出项目成员参数 */
interface BatchInactiveProjectMembersParams {
memberIds: string[];
reason?: string | null;
}
/**
* 项目创建(含初始团队)原子接口参数
*
* 新增项目两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProjectWithTeamParams {
project: SaveProjectParams;
members: CreateProjectMemberParams[];
/** 关注人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
watcherUserIds?: string[];
}
// ========== 项目需求相关类型定义 ==========
/** 项目需求状态编码 */
type ProjectRequirementStatusCode =
| 'pending_claim'
| 'pending_review'
| 'reviewed'
| 'review_rejected'
| 'implementing'
| 'accepted'
| 'closed'
| 'rejected'
| 'cancelled';
/** 项目需求状态动作编码 */
type ProjectRequirementStatusActionCode =
| 'claim_to_review'
| 'claim_to_implement'
| 'pass_review'
| 'reject_review'
| 'start_implement'
| 'accept'
| 'cancel'
| 'close'
| 'reject';
/** 项目需求来源类型 */
type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
/** 项目需求优先级 */
type ProjectRequirementPriority = 0 | 1 | 2 | 3;
/** 是否需要评审 */
type ProjectRequirementReviewRequired = 0 | 1;
interface ProjectRequirement {
/** 需求 ID */
id: string;
/** 所属项目 ID */
projectId: string;
/** 父需求 ID0 表示顶级需求 */
parentId: string;
/** 所属模块 ID */
moduleId: string;
/** 是否需要评审 */
reviewRequired: ProjectRequirementReviewRequired;
/** 需求标题 */
title: string;
/** 需求描述 */
description?: string | null;
/** 附件列表 */
attachments?: AttachmentItem[] | null;
/** 需求分类字典值 */
category: string;
/** 需求分类名称 */
categoryName?: string | null;
/** 需求来源类型 */
sourceType: ProjectRequirementSourceType;
/** 来源业务 ID */
sourceBizId?: string | null;
/** 优先级 */
priority: ProjectRequirementPriority;
/** 优先级名称 */
priorityName?: string | null;
/** 当前状态编码 */
statusCode: ProjectRequirementStatusCode;
/** 当前状态名称 */
statusName?: string | null;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
/** 提出人用户 ID */
proposerId: string;
/** 提出人昵称 */
proposerNickname?: string | null;
/** 当前处理人用户 ID */
currentHandlerUserId?: string | null;
/** 当前处理人昵称 */
currentHandlerUserNickname?: string | null;
/** 预期完成日期 */
expectedTime?: string | null;
/** 排序值 */
sort: number;
/** 项目需求进度BigDecimal0.00 ~ 1.00HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
progressRate: number;
/** 创建时间 */
createTime: string;
/** 更新时间 */
updateTime: string;
/** 子需求列表 */
children?: ProjectRequirement[];
}
interface ProjectRequirementModule {
/** 模块 ID */
id: string;
/** 父模块 ID0 表示顶级 */
parentId: string;
/** 所属项目 ID */
projectId: string;
/** 模块名称 */
moduleName: string;
/** 模块说明 */
remark?: string | null;
/** 图标 */
icon?: string | null;
/** 排序值 */
sort: number;
/** 子模块列表 */
children?: ProjectRequirementModule[];
}
interface ProjectRequirementStatusDict {
/** 状态编码 */
statusCode: string;
/** 状态名称 */
statusName: string;
/** 排序值 */
sort: number;
/** 是否初始状态 */
initialFlag: boolean;
/** 是否终态 */
terminalFlag: boolean;
/** 是否允许编辑 */
allowEdit: boolean;
}
interface ProjectRequirementLifecycleAction {
actionCode: ProjectRequirementStatusActionCode;
actionName: string;
toStatusCode: string;
toStatusName: string;
needReason: boolean;
}
interface ProjectRequirementBatchReqVO {
projectId: string;
requirementIds: string[];
}
interface ProjectRequirementAllowedTransitionBatchRespVO {
requirementId: string;
transitions: ProjectRequirementLifecycleAction[];
}
type ProjectRequirementReviewConclusion = 0 | 1;
interface ProjectRequirementReviewAttendeeItem {
userId: string;
nickname: string;
}
interface ProjectRequirementReview {
id: string;
objectType: 'project_requirement';
requirementId: string;
operatorId: string;
conclusion: ProjectRequirementReviewConclusion;
reviewContent?: string | null;
requirementEstimatedHours?: number | string | null;
attendees?: ProjectRequirementReviewAttendeeItem[];
attachments?: AttachmentItem[] | null;
reviewTime?: string | null;
createTime?: string;
updateTime?: string;
}
interface ProjectRequirementReviewSubmitParams {
projectId: string;
requirementId: string;
operatorId: string;
conclusion: ProjectRequirementReviewConclusion;
reviewContent?: string | null;
requirementEstimatedHours?: number | string | null;
attendees?: ProjectRequirementReviewAttendeeItem[];
attachments?: AttachmentItem[] | null;
reviewTime?: string | null;
}
/** 项目需求分页查询参数 */
type ProjectRequirementSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<
ProjectRequirement,
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
> & {
projectId: string;
title: string;
}
>;
/** 创建项目需求参数 */
type SaveProjectRequirementParams = Pick<
ProjectRequirement,
| 'projectId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'expectedTime'
| 'sort'
>;
/** 更新项目需求参数 */
type UpdateProjectRequirementParams = { id: string } & SaveProjectRequirementParams;
/** 变更项目需求状态参数 */
interface ChangeProjectRequirementStatusParams {
id: string;
projectId: string;
actionCode: string;
reason?: string | null;
}
/** 关闭项目需求参数 */
interface CloseProjectRequirementParams {
id: string;
projectId: string;
reason: string;
}
/** 拆分项目需求参数 */
type SplitProjectRequirementParams = Pick<
ProjectRequirement,
| 'parentId'
| 'projectId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'expectedTime'
| 'sort'
>;
/** 删除项目需求参数 */
interface DeleteProjectRequirementParams {
id: string;
projectId: string;
}
/** 保存项目需求模块参数 */
interface SaveProjectRequirementModuleParams {
id?: string;
projectId: string;
parentId?: string | null;
moduleName: string;
remark?: string | null;
icon?: string | null;
sort?: number;
}
/** 删除项目需求模块参数 */
interface DeleteProjectRequirementModuleParams {
id?: string;
projectId: string;
}
}
}

View File

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

View File

@@ -333,7 +333,7 @@ declare namespace App {
trigger: string;
update: string;
updateSuccess: string;
userCenter: string;
myProfile: string;
yesOrNo: {
yes: string;
no: string;
@@ -684,6 +684,7 @@ declare namespace App {
orgType: {
company: string;
dept: string;
function: string;
direction: string;
team: string;
};

View File

@@ -9,7 +9,9 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
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']
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.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']
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.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']
ButtonIcon: typeof import('./../components/custom/button-icon.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']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -97,10 +102,19 @@ declare module 'vue' {
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['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']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/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']
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
@@ -108,6 +122,7 @@ declare module 'vue' {
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['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']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
@@ -130,7 +145,9 @@ declare module 'vue' {
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
@@ -140,6 +157,7 @@ declare module 'vue' {
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['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']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
@@ -164,6 +182,7 @@ declare module 'vue' {
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.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']
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
}

View File

@@ -35,7 +35,22 @@ declare module "@elegant-router/types" {
"function_tab": "/function/tab";
"function_toggle-auth": "/function/toggle-auth";
"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)?";
"metrics": "/metrics";
"metrics_member-efficiency": "/metrics/member-efficiency";
"metrics_project-progress": "/metrics/project-progress";
"metrics_worktime": "/metrics/worktime";
"personal-center": "/personal-center";
"personal-center_my-application": "/personal-center/my-application";
"personal-center_my-item": "/personal-center/my-item";
"personal-center_my-monthly": "/personal-center/my-monthly";
"personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin";
"plugin_barcode": "/plugin/barcode";
"plugin_charts": "/plugin/charts";
@@ -43,9 +58,6 @@ declare module "@elegant-router/types" {
"plugin_charts_echarts": "/plugin/charts/echarts";
"plugin_charts_vchart": "/plugin/charts/vchart";
"plugin_copy": "/plugin/copy";
"plugin_editor": "/plugin/editor";
"plugin_editor_markdown": "/plugin/editor/markdown";
"plugin_editor_quill": "/plugin/editor/quill";
"plugin_excel": "/plugin/excel";
"plugin_gantt": "/plugin/gantt";
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
@@ -80,7 +92,10 @@ declare module "@elegant-router/types" {
"system_user": "/system/user";
"system_user-detail": "/system/user-detail/:id";
"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";
};
/**
@@ -121,12 +136,16 @@ declare module "@elegant-router/types" {
| "500"
| "function"
| "iframe-page"
| "infra"
| "login"
| "metrics"
| "personal-center"
| "plugin"
| "product"
| "project"
| "system"
| "user-center"
| "ticket"
| "workbench"
>;
/**
@@ -157,13 +176,23 @@ declare module "@elegant-router/types" {
| "function_super-page"
| "function_tab"
| "function_toggle-auth"
| "infra_rd-code"
| "infra_state-machine"
| "metrics_member-efficiency"
| "metrics_project-progress"
| "metrics_worktime"
| "personal-center_my-application"
| "personal-center_my-item"
| "personal-center_my-monthly"
| "personal-center_my-performance"
| "personal-center_my-profile"
| "personal-center_my-weekly"
| "personal-center_pending-approval"
| "plugin_barcode"
| "plugin_charts_antv"
| "plugin_charts_echarts"
| "plugin_charts_vchart"
| "plugin_copy"
| "plugin_editor_markdown"
| "plugin_editor_quill"
| "plugin_excel"
| "plugin_gantt_dhtmlx"
| "plugin_gantt_vtable"
@@ -192,7 +221,9 @@ declare module "@elegant-router/types" {
| "system_user-detail"
| "system_user-management-relation"
| "system_user"
| "user-center"
| "ticket_my-pending"
| "ticket_my-submitted"
| "workbench"
>;
/**

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="研发令号" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,395 @@
<script setup lang="tsx">
import { computed, nextTick, onActivated, reactive, ref } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElTag } from 'element-plus';
import { useBoolean } from '@sa/hooks';
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
import {
fetchBatchDeleteObjectStatusModel,
fetchDeleteObjectStatusModel,
fetchGetObjectStatusModelPage
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useDict } from '@/hooks/business/dict';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import StateMachineOperateDialog from './modules/state-machine-operate-dialog.vue';
import StateMachineSearch from './modules/state-machine-search.vue';
import StateTransitionDialog from './modules/state-transition-dialog.vue';
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSourceBranch from '~icons/mdi/source-branch';
defineOptions({ name: 'StateMachineManage' });
function getInitSearchParams(): Api.Infra.ObjectStatusModelSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
objectType: undefined,
status: undefined,
initialFlag: undefined,
terminalFlag: undefined
};
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetObjectStatusModelPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const stateTableRef = ref<TableInstance>();
const checkedRowKeys = ref<string[]>([]);
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
const { hasAuth } = useAuth();
const canDeleteStateMachine = computed(() => hasAuth('infra:state-machine:delete'));
const canUpdateStateMachine = computed(() => hasAuth('infra:state-machine:update'));
const canManageStateTransition = computed(() => hasAuth('infra:state-transition:manage'));
function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableAction[] {
const actions: BusinessTableAction[] = [];
if (canManageStateTransition.value) {
actions.push({
key: 'transition',
label: '状态流转',
icon: IconMdiSourceBranch,
buttonType: 'primary',
onClick: () => openTransitionDialog(row)
});
}
if (canUpdateStateMachine.value) {
actions.push({
key: 'edit',
label: '编辑',
icon: IconMdiPencilOutline,
buttonType: 'primary',
onClick: () => openEdit(row)
});
}
if (canDeleteStateMachine.value) {
actions.push({
key: 'delete',
label: '删除',
icon: IconMdiDeleteOutline,
buttonType: 'danger',
onClick: () => handleDeleteAction(row)
});
}
return actions;
}
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetObjectStatusModelPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'objectType',
label: '对象类型',
minWidth: 130,
formatter: row => getObjectTypeLabel(row.objectType)
},
{ prop: 'statusName', label: '状态名称', minWidth: 140, showOverflowTooltip: true },
{ prop: 'statusCode', label: '状态编码', minWidth: 160, showOverflowTooltip: true },
{
prop: 'status',
label: '配置状态',
width: 110,
align: 'center',
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
},
{
prop: 'initialFlag',
label: '初始状态',
width: 110,
align: 'center',
formatter: row => <ElTag type={getBooleanTagType(row.initialFlag)}>{getBooleanLabel(row.initialFlag)}</ElTag>
},
{
prop: 'terminalFlag',
label: '终态',
width: 90,
align: 'center',
formatter: row => <ElTag type={getBooleanTagType(row.terminalFlag)}>{getBooleanLabel(row.terminalFlag)}</ElTag>
},
{
prop: 'allowEdit',
label: '允许编辑主数据',
width: 140,
align: 'center',
formatter: row => <ElTag type={getBooleanTagType(row.allowEdit)}>{getBooleanLabel(row.allowEdit)}</ElTag>
},
// {
// prop: 'progressExcludedFlag',
// label: '不参与上层进度统计',
// width: 160,
// align: 'center',
// formatter: row => (
// <ElTag type={getBooleanTagType(row.progressExcludedFlag)}>{getBooleanLabel(row.progressExcludedFlag)}</ElTag>
// )
// },
// {
// prop: 'allowCreateProject',
// label: '允许新建项目',
// width: 130,
// align: 'center',
// formatter: row => (
// <ElTag type={getBooleanTagType(row.allowCreateProject)}>{getBooleanLabel(row.allowCreateProject)}</ElTag>
// )
// },
// {
// prop: 'allowCreateRequirement',
// label: '允许新增需求',
// width: 130,
// align: 'center',
// formatter: row => (
// <ElTag type={getBooleanTagType(row.allowCreateRequirement)}>
// {getBooleanLabel(row.allowCreateRequirement)}
// </ElTag>
// )
// },
{ prop: 'sort', label: '排序', width: 90, align: 'center' },
{
prop: 'remark',
label: '备注',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.remark || '--'
},
{
prop: 'createTime',
label: '创建时间',
minWidth: 170,
formatter: row => formatDateTime(row.createTime)
},
{
prop: 'operate',
label: '操作',
width: 220,
align: 'center',
fixed: 'right',
formatter: row => {
const actions = getStatusModelActions(row);
if (!actions.length) {
return <span>--</span>;
}
return <BusinessTableActionCell actions={actions} variant="icon" />;
}
}
]
});
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
const operateType = ref<UI.TableOperateType>('add');
const editingData = ref<Api.Infra.ObjectStatusModel | null>(null);
const { bool: transitionVisible, setTrue: openTransitionModal, setFalse: closeTransitionModal } = useBoolean();
const transitionRow = ref<Api.Infra.ObjectStatusModel | null>(null);
function openAdd() {
operateType.value = 'add';
editingData.value = null;
openOperateModal();
}
function openEdit(item: Api.Infra.ObjectStatusModel) {
operateType.value = 'edit';
editingData.value = item;
openOperateModal();
}
function openTransitionDialog(item: Api.Infra.ObjectStatusModel) {
transitionRow.value = item;
openTransitionModal();
}
async function handleDelete(item: Api.Infra.ObjectStatusModel) {
const { error } = await fetchDeleteObjectStatusModel(item.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadStatusTable();
}
async function handleDeleteAction(row: Api.Infra.ObjectStatusModel) {
try {
await window.$messageBox?.confirm('确认删除当前状态模型吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await handleDelete(row);
}
async function handleBatchDelete() {
if (!checkedRowKeys.value.length) {
return;
}
const { error } = await fetchBatchDeleteObjectStatusModel(checkedRowKeys.value);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadStatusTable();
}
function handleSelectionChange(rows: Api.Infra.ObjectStatusModel[]) {
checkedRowKeys.value = rows.map(item => item.id);
}
async function reloadStatusTable(page = searchParams.pageNo) {
checkedRowKeys.value = [];
await getDataByPage(page);
await nextTick();
stateTableRef.value?.clearSelection();
}
function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams());
reloadStatusTable(1);
}
function handleSearch() {
reloadStatusTable(1);
}
function handleSubmitted() {
closeOperateModal();
reloadStatusTable();
}
onActivated(() => {
resetSearchParams();
});
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<StateMachineSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>状态模型列表</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton v-auth="'infra:state-machine:create'" plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
<ElPopconfirm
v-if="canDeleteStateMachine"
title="确认删除选中的状态模型吗?"
@confirm="handleBatchDelete"
>
<template #reference>
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
批量删除
</ElButton>
</template>
</ElPopconfirm>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
ref="stateTableRef"
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<StateMachineOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="handleSubmitted"
/>
<StateTransitionDialog
v-model:visible="transitionVisible"
:current-status="transitionRow"
@update:visible="value => !value && closeTransitionModal()"
/>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateObjectStatusModel, fetchGetObjectStatusModel, fetchUpdateObjectStatusModel } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { statusOptions } from '../shared';
defineOptions({ name: 'StateMachineOperateDialog' });
interface Props {
operateType: UI.TableOperateType;
rowData?: Api.Infra.ObjectStatusModel | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [statusModelId: string];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { dictOptions: objectTypeOptions } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
const detailLoading = ref(false);
const submitting = ref(false);
const isEdit = computed(() => props.operateType === 'edit');
const title = computed(() => {
const titleMap: Record<UI.TableOperateType, string> = {
add: '新增状态模型',
edit: '编辑状态模型'
};
return titleMap[props.operateType];
});
type Model = Api.Infra.SaveObjectStatusModelParams;
const model = ref(createDefaultModel());
function createDefaultModel(): Model {
return {
objectType: 'product',
statusCode: '',
statusName: '',
sort: 0,
status: 0,
initialFlag: false,
terminalFlag: false,
allowEdit: false,
progressExcludedFlag: false,
allowCreateProject: false,
allowCreateRequirement: false,
remark: ''
};
}
const rules = {
objectType: createRequiredRule('请选择对象类型'),
statusCode: createRequiredRule('请输入状态编码'),
statusName: createRequiredRule('请输入状态名称'),
sort: createRequiredRule('请输入排序值'),
status: createRequiredRule('请选择配置状态')
} satisfies Record<string, App.Global.FormRule>;
function closeModal() {
visible.value = false;
}
async function initModel() {
model.value = createDefaultModel();
if (!isEdit.value || !props.rowData) {
await nextTick();
formRef.value?.clearValidate();
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetObjectStatusModel(props.rowData.id);
detailLoading.value = false;
if (!error) {
model.value = {
objectType: data.objectType,
statusCode: data.statusCode,
statusName: data.statusName,
sort: data.sort ?? 0,
status: data.status,
initialFlag: data.initialFlag,
terminalFlag: data.terminalFlag,
allowEdit: data.allowEdit,
progressExcludedFlag: data.progressExcludedFlag,
allowCreateProject: data.allowCreateProject,
allowCreateRequirement: data.allowCreateRequirement,
remark: data.remark ?? ''
};
}
await nextTick();
formRef.value?.clearValidate();
}
async function handleSubmit() {
await validate();
submitting.value = true;
const submitData: Api.Infra.SaveObjectStatusModelParams = {
...model.value,
statusCode: model.value.statusCode.trim(),
statusName: model.value.statusName.trim(),
remark: model.value.remark?.trim() || null
};
let statusModelId = props.rowData?.id ?? '';
if (isEdit.value && props.rowData) {
const { error } = await fetchUpdateObjectStatusModel({ id: props.rowData.id, ...submitData });
submitting.value = false;
if (error) {
return;
}
} else {
const { error, data } = await fetchCreateObjectStatusModel(submitData);
submitting.value = false;
if (error) {
return;
}
statusModelId = data;
}
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
closeModal();
emit('submitted', statusModelId);
}
watch(visible, value => {
if (value) {
initModel();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:loading="detailLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="对象类型" prop="objectType">
<ElSelect
v-model="model.objectType"
class="w-full"
placeholder="请选择或输入对象类型"
filterable
allow-create
default-first-option
clearable
:reserve-keyword="false"
>
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="状态编码" prop="statusCode">
<ElInput v-model="model.statusCode" placeholder="请输入状态编码" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="状态名称" prop="statusName">
<ElInput v-model="model.statusName" placeholder="请输入状态名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值" prop="sort">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="配置状态" prop="status">
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
<ElRadio v-for="{ label, value } in statusOptions" :key="value" :value="value">
{{ label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否初始状态" prop="initialFlag">
<div class="business-form-switch-field">
<ElSwitch v-model="model.initialFlag" />
<span class="ml-8px text-12px text-[#606266]">{{ model.initialFlag ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否终态" prop="terminalFlag">
<div class="business-form-switch-field">
<ElSwitch v-model="model.terminalFlag" />
<span class="ml-8px text-12px text-[#606266]">{{ model.terminalFlag ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="允许编辑主数据" prop="allowEdit">
<div class="business-form-switch-field">
<ElSwitch v-model="model.allowEdit" />
<span class="ml-8px text-12px text-[#606266]">{{ model.allowEdit ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="不参与上层进度统计" prop="progressExcludedFlag">
<div class="business-form-switch-field">
<ElSwitch v-model="model.progressExcludedFlag" />
<span class="ml-8px text-12px text-[#606266]">{{ model.progressExcludedFlag ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="允许新建项目" prop="allowCreateProject">
<div class="business-form-switch-field">
<ElSwitch v-model="model.allowCreateProject" />
<span class="ml-8px text-12px text-[#606266]">{{ model.allowCreateProject ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="允许新增需求" prop="allowCreateRequirement">
<div class="business-form-switch-field">
<ElSwitch v-model="model.allowCreateRequirement" />
<span class="ml-8px text-12px text-[#606266]">{{ model.allowCreateRequirement ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注" prop="remark">
<ElInput v-model="model.remark" type="textarea" :rows="4" placeholder="请输入备注" />
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { statusOptions } from '../shared';
defineOptions({ name: 'StateMachineSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.Infra.ObjectStatusModelSearchParams>('model', { required: true });
const booleanOptions = [
{ label: '是', value: 1 },
{ label: '否', value: 0 }
];
const searchModel = reactive<{
keyword: string;
objectType?: string;
status?: Api.Infra.CommonStatus;
initialFlag?: number;
terminalFlag?: number;
}>({
keyword: '',
objectType: undefined,
status: undefined,
initialFlag: undefined,
terminalFlag: undefined
});
let syncingFromSource = false;
watch(
() =>
[
model.value.keyword,
model.value.objectType,
model.value.status,
model.value.initialFlag,
model.value.terminalFlag
] as const,
([keyword, objectType, status, initialFlag, terminalFlag]) => {
syncingFromSource = true;
searchModel.keyword = keyword ?? '';
searchModel.objectType = objectType;
searchModel.status = status;
if (initialFlag === undefined) {
searchModel.initialFlag = undefined;
} else {
searchModel.initialFlag = initialFlag ? 1 : 0;
}
if (terminalFlag === undefined) {
searchModel.terminalFlag = undefined;
} else {
searchModel.terminalFlag = terminalFlag ? 1 : 0;
}
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() =>
[
searchModel.keyword,
searchModel.objectType,
searchModel.status,
searchModel.initialFlag,
searchModel.terminalFlag
] as const,
([keywordValue, objectType, status, initialFlag, terminalFlag]) => {
if (syncingFromSource) {
return;
}
model.value.keyword = keywordValue.trim() || undefined;
model.value.objectType = objectType;
model.value.status = status;
model.value.initialFlag = initialFlag === undefined ? undefined : initialFlag === 1;
model.value.terminalFlag = terminalFlag === undefined ? undefined : terminalFlag === 1;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'objectType',
label: '对象类型',
type: 'dict',
placeholder: '请选择对象类型',
dictCode: OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE
},
{
key: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入状态名称或状态编码'
},
{
key: 'status',
label: '配置状态',
type: 'select',
placeholder: '请选择配置状态',
options: statusOptions
},
{
key: 'initialFlag',
label: '初始状态',
type: 'select',
placeholder: '请选择是否初始状态',
options: booleanOptions
},
{
key: 'terminalFlag',
label: '终态',
type: 'select',
placeholder: '请选择是否终态',
options: booleanOptions
}
]);
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,406 @@
<script setup lang="tsx">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElTag } from 'element-plus';
import { useBoolean } from '@sa/hooks';
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
import {
fetchBatchDeleteObjectStatusTransition,
fetchDeleteObjectStatusTransition,
fetchGetObjectStatusModelPage,
fetchGetObjectStatusTransitionPage
} from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from '../shared';
import StateTransitionOperateDialog from './state-transition-operate-dialog.vue';
import StateTransitionSearch from './state-transition-search.vue';
defineOptions({ name: 'StateTransitionDialog' });
interface Props {
currentStatus?: Api.Infra.ObjectStatusModel | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
function getInitSearchParams(): Api.Infra.ObjectStatusTransitionSearchParams {
return {
pageNo: 1,
pageSize: 10,
objectType: props.currentStatus?.objectType,
fromStatusCode: props.currentStatus?.statusCode,
actionCode: undefined,
actionName: undefined,
toStatusCode: undefined,
status: undefined
};
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetObjectStatusTransitionPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const transitionTableRef = ref<TableInstance>();
const checkedRowKeys = ref<string[]>([]);
const statusModelOptions = ref<Api.Infra.ObjectStatusModel[]>([]);
const loadingOptions = ref(false);
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
const targetStatusOptions = computed(() =>
statusModelOptions.value.map(item => ({
label: `${item.statusName} (${item.statusCode})`,
value: item.statusCode
}))
);
const currentStatusLabel = computed(() => {
if (!props.currentStatus) {
return '--';
}
return `${props.currentStatus.statusName} (${props.currentStatus.statusCode})`;
});
const currentObjectTypeLabel = computed(() => getObjectTypeLabel(props.currentStatus?.objectType));
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetObjectStatusTransitionPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'actionName', label: '动作名称', minWidth: 150, showOverflowTooltip: true },
{ prop: 'actionCode', label: '动作编码', minWidth: 150, showOverflowTooltip: true },
{
prop: 'toStatusCode',
label: '目标状态',
minWidth: 180,
formatter: row => row.toStatusName?.trim() || row.toStatusCode
},
{
prop: 'needReason',
label: '必须填写原因',
width: 120,
align: 'center',
formatter: row => <ElTag type={getBooleanTagType(row.needReason)}>{getBooleanLabel(row.needReason)}</ElTag>
},
{
prop: 'status',
label: '配置状态',
width: 110,
align: 'center',
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
},
{
prop: 'remark',
label: '备注',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.remark || '--'
},
{
prop: 'createTime',
label: '创建时间',
minWidth: 170,
formatter: row => formatDateTime(row.createTime)
},
{
prop: 'operate',
label: '操作',
width: 180,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
onClick: () => openEdit(row)
},
{
key: 'delete',
label: '删除',
buttonType: 'danger',
onClick: () => handleDeleteAction(row)
}
]}
/>
)
}
]
});
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
const operateType = ref<UI.TableOperateType>('add');
const editingData = ref<Api.Infra.ObjectStatusTransition | null>(null);
function openAdd() {
operateType.value = 'add';
editingData.value = null;
openOperateModal();
}
function openEdit(item: Api.Infra.ObjectStatusTransition) {
operateType.value = 'edit';
editingData.value = item;
openOperateModal();
}
async function handleDelete(item: Api.Infra.ObjectStatusTransition) {
const { error } = await fetchDeleteObjectStatusTransition(item.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadTable();
}
async function handleDeleteAction(row: Api.Infra.ObjectStatusTransition) {
try {
await window.$messageBox?.confirm('确认删除当前状态流转吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await handleDelete(row);
}
async function handleBatchDelete() {
if (!checkedRowKeys.value.length) {
return;
}
const { error } = await fetchBatchDeleteObjectStatusTransition(checkedRowKeys.value);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadTable();
}
function handleSelectionChange(rows: Api.Infra.ObjectStatusTransition[]) {
checkedRowKeys.value = rows.map(item => item.id);
}
async function reloadTable(page = searchParams.pageNo) {
checkedRowKeys.value = [];
await getDataByPage(page);
await nextTick();
transitionTableRef.value?.clearSelection();
}
function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
closeOperateModal();
reloadTable();
}
async function loadStatusModelOptions() {
if (!props.currentStatus?.objectType) {
statusModelOptions.value = [];
return;
}
loadingOptions.value = true;
const { error, data: page } = await fetchGetObjectStatusModelPage({
pageNo: 1,
pageSize: 200,
keyword: undefined,
objectType: props.currentStatus.objectType,
status: undefined,
initialFlag: undefined,
terminalFlag: undefined
});
loadingOptions.value = false;
statusModelOptions.value = error ? [] : page.list;
}
async function initDialog() {
if (!props.currentStatus) {
return;
}
Object.assign(searchParams, getInitSearchParams(), {
objectType: props.currentStatus.objectType,
fromStatusCode: props.currentStatus.statusCode
});
checkedRowKeys.value = [];
await Promise.all([loadStatusModelOptions(), reloadTable(1)]);
}
watch(
() => [visible.value, props.currentStatus?.id] as const,
([opened]) => {
if (opened) {
initDialog();
}
},
{ immediate: true }
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="状态流转配置"
width="1200px"
:loading="loadingOptions"
:show-footer="false"
:scrollbar="false"
>
<div v-if="currentStatus" class="state-transition-dialog">
<StateTransitionSearch
v-model:model="searchParams"
:target-status-options="targetStatusOptions"
@reset="resetSearchParams"
@search="handleSearch"
/>
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="min-w-0 flex flex-wrap items-center gap-8px">
<p>状态流转列表</p>
<ElTag type="primary" effect="light">
{{ currentObjectTypeLabel }}
</ElTag>
<ElTag type="success" effect="light">
{{ currentStatusLabel }}
</ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
@refresh="getData"
>
<template #default>
<ElButton plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
<ElPopconfirm title="确认删除选中的状态流转吗?" @confirm="handleBatchDelete">
<template #reference>
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
批量删除
</ElButton>
</template>
</ElPopconfirm>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
ref="transitionTableRef"
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
<div v-else class="h-full flex items-center justify-center">
<ElEmpty description="请选择状态模型" />
</div>
<StateTransitionOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="editingData"
:current-status="currentStatus"
:target-status-options="targetStatusOptions"
append-to-body
@submitted="handleSubmitted"
/>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.state-transition-dialog {
display: flex;
min-height: 560px;
flex-direction: column;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
import {
fetchCreateObjectStatusTransition,
fetchGetObjectStatusTransition,
fetchUpdateObjectStatusTransition
} from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { statusOptions } from '../shared';
defineOptions({ name: 'StateTransitionOperateDialog' });
interface Props {
operateType: UI.TableOperateType;
rowData?: Api.Infra.ObjectStatusTransition | null;
currentStatus?: Api.Infra.ObjectStatusModel | null;
targetStatusOptions: Array<{ label: string; value: string }>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [transitionId: string];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
const detailLoading = ref(false);
const submitting = ref(false);
const isEdit = computed(() => props.operateType === 'edit');
const title = computed(() => {
const titleMap: Record<UI.TableOperateType, string> = {
add: '新增状态流转',
edit: '编辑状态流转'
};
return titleMap[props.operateType];
});
type Model = Api.Infra.SaveObjectStatusTransitionParams;
const model = ref(createDefaultModel());
const currentObjectTypeLabel = computed(() => getObjectTypeLabel(model.value.objectType));
const currentFromStatusLabel = computed(() => {
if (!props.currentStatus) {
return model.value.fromStatusCode || '--';
}
return `${props.currentStatus.statusName} (${props.currentStatus.statusCode})`;
});
function createDefaultModel(): Model {
return {
objectType: props.currentStatus?.objectType ?? 'product',
actionCode: '',
actionName: '',
fromStatusCode: props.currentStatus?.statusCode ?? '',
toStatusCode: '',
needReason: false,
status: 0,
remark: ''
};
}
const rules = {
actionCode: createRequiredRule('请输入动作编码'),
actionName: createRequiredRule('请输入动作名称'),
toStatusCode: createRequiredRule('请选择目标状态'),
status: createRequiredRule('请选择配置状态')
} satisfies Record<string, App.Global.FormRule>;
function closeModal() {
visible.value = false;
}
async function initModel() {
model.value = createDefaultModel();
if (!isEdit.value || !props.rowData) {
await nextTick();
formRef.value?.clearValidate();
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetObjectStatusTransition(props.rowData.id);
detailLoading.value = false;
if (!error) {
model.value = {
objectType: data.objectType,
actionCode: data.actionCode,
actionName: data.actionName,
fromStatusCode: data.fromStatusCode,
toStatusCode: data.toStatusCode,
needReason: data.needReason,
status: data.status,
remark: data.remark ?? ''
};
}
await nextTick();
formRef.value?.clearValidate();
}
async function handleSubmit() {
await validate();
submitting.value = true;
const submitData: Api.Infra.SaveObjectStatusTransitionParams = {
...model.value,
objectType: props.currentStatus?.objectType ?? model.value.objectType,
fromStatusCode: props.currentStatus?.statusCode ?? model.value.fromStatusCode,
actionCode: model.value.actionCode.trim(),
actionName: model.value.actionName.trim(),
remark: model.value.remark?.trim() || null
};
let transitionId = props.rowData?.id ?? '';
if (isEdit.value && props.rowData) {
const { error } = await fetchUpdateObjectStatusTransition({ id: props.rowData.id, ...submitData });
submitting.value = false;
if (error) {
return;
}
} else {
const { error, data } = await fetchCreateObjectStatusTransition(submitData);
submitting.value = false;
if (error) {
return;
}
transitionId = data;
}
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
closeModal();
emit('submitted', transitionId);
}
watch(visible, value => {
if (value) {
initModel();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="md"
:loading="detailLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="对象类型">
<ElInput :model-value="currentObjectTypeLabel" readonly />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="起始状态">
<ElInput :model-value="currentFromStatusLabel" readonly />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="动作编码" prop="actionCode">
<ElInput v-model="model.actionCode" placeholder="请输入动作编码" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="动作名称" prop="actionName">
<ElInput v-model="model.actionName" placeholder="请输入动作名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="目标状态" prop="toStatusCode">
<ElSelect v-model="model.toStatusCode" class="w-full" placeholder="请选择目标状态">
<ElOption v-for="{ label, value } in targetStatusOptions" :key="value" :label="label" :value="value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="配置状态" prop="status">
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
<ElRadio v-for="{ label, value } in statusOptions" :key="value" :value="value">
{{ label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="必须填写原因" prop="needReason">
<div class="business-form-switch-field">
<ElSwitch v-model="model.needReason" />
<span class="ml-8px text-12px text-[#606266]">{{ model.needReason ? '是' : '否' }}</span>
</div>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注" prop="remark">
<ElInput v-model="model.remark" type="textarea" :rows="4" placeholder="请输入备注" />
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { statusOptions } from '../shared';
defineOptions({ name: 'StateTransitionSearch' });
interface Props {
targetStatusOptions: Array<{ label: string; value: string }>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.Infra.ObjectStatusTransitionSearchParams>('model', { required: true });
const searchModel = reactive<{
keyword: string;
toStatusCode?: string;
status?: Api.Infra.CommonStatus;
}>({
keyword: '',
toStatusCode: undefined,
status: undefined
});
let syncingFromSource = false;
watch(
() => [model.value.actionName, model.value.actionCode, model.value.toStatusCode, model.value.status] as const,
([actionName, actionCode, toStatusCode, status]) => {
syncingFromSource = true;
searchModel.keyword = actionName ?? actionCode ?? '';
searchModel.toStatusCode = toStatusCode;
searchModel.status = status;
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() => [searchModel.keyword, searchModel.toStatusCode, searchModel.status] as const,
([keywordValue, toStatusCode, status]) => {
if (syncingFromSource) {
return;
}
const keywordText = keywordValue.trim() || undefined;
model.value.actionName = keywordText;
model.value.actionCode = keywordText;
model.value.toStatusCode = toStatusCode;
model.value.status = status;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '动作名称',
type: 'input',
placeholder: '请输入动作名称或动作编码'
},
{
key: 'toStatusCode',
label: '目标状态',
type: 'select',
placeholder: '请选择目标状态',
options: props.targetStatusOptions
},
{
key: 'status',
label: '配置状态',
type: 'select',
placeholder: '请选择配置状态',
options: statusOptions
}
]);
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,38 @@
import dayjs from 'dayjs';
export const statusOptions: Array<{ label: string; value: Api.Infra.CommonStatus }> = [
{ label: '启用', value: 0 },
{ label: '停用', value: 1 }
];
export function getStatusLabel(value?: Api.Infra.CommonStatus | null) {
if (value === 0) {
return '启用';
}
if (value === 1) {
return '停用';
}
return '--';
}
export function getStatusTagType(value?: Api.Infra.CommonStatus | null): UI.ThemeColor {
return value === 0 ? 'success' : 'warning';
}
export function getBooleanLabel(value?: boolean | null) {
return value ? '是' : '否';
}
export function getBooleanTagType(value?: boolean | null): UI.ThemeColor {
return value ? 'success' : 'info';
}
export function formatDateTime(value?: string | number | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="员工能效" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="项目进度" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="工时统计" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的申请" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,667 @@
<script setup lang="tsx">
import { computed, markRaw, nextTick, onActivated, reactive, ref } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import { useBoolean } from '@sa/hooks';
import {
fetchBatchDeletePersonalItems,
fetchBindPersonalItemsToExecution,
fetchChangePersonalItemStatus,
fetchDeletePersonalItem,
fetchGetPersonalItemDetail,
fetchGetPersonalItemPage
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import PersonalItemBindExecutionDialog from './modules/personal-item-bind-execution-dialog.vue';
import PersonalItemDetailDialog from './modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from './modules/personal-item-operate-dialog.vue';
import PersonalItemSearch from './modules/personal-item-search.vue';
import PersonalItemStatusActionDialog from './modules/personal-item-status-action-dialog.vue';
import {
formatPersonalItemDateRange,
formatPersonalItemDateTime,
formatPersonalItemOwnerName,
formatPersonalItemProgress,
getPersonalItemStatusLabel,
resolvePersonalItemStatusTagType
} from './modules/personal-item-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiPlay from '~icons/mdi/play';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'MyItem' });
type DetailTab = 'worklog';
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface PersonalItemRowAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'warning' | 'danger';
disabled?: boolean;
onClick: () => void | Promise<void>;
}
const lifecycleActionIconMap: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
reopen: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
const lifecycleActionTypeMap: Record<string, PersonalItemRowAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary',
reopen: 'primary',
start: 'primary'
};
const lifecycleActionOrder: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4,
reopen: 5,
start: 6
};
const authStore = useAuthStore();
const currentUserId = computed(() => {
const rawUserId = authStore.userInfo.userId;
return rawUserId ? String(rawUserId) : '';
});
function getInitSearchParams(): Api.PersonalItem.PersonalItemSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
ownerId: currentUserId.value || undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetPersonalItemPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const tableRef = ref<TableInstance>();
const checkedRowIds = ref<string[]>([]);
const bindExecutionSubmitting = ref(false);
const selectedCount = computed(() => checkedRowIds.value.length);
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetPersonalItemPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'taskTitle',
label: '事项标题',
minWidth: 260,
showOverflowTooltip: true
},
{
prop: 'ownerName',
label: '负责人',
minWidth: 140,
formatter: row => formatPersonalItemOwnerName(row)
},
{
prop: 'statusCode',
label: '状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={resolvePersonalItemStatusTagType(row.statusCode)}>
{getPersonalItemStatusLabel(row.statusCode)}
</ElTag>
)
},
{
prop: 'progressRate',
label: '进度',
width: 100,
align: 'center',
formatter: row => formatPersonalItemProgress(row.progressRate)
},
{
prop: 'plannedDateRange',
label: '计划日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.plannedStartDate, row.plannedEndDate)
},
{
prop: 'actualDateRange',
label: '实际日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.actualStartDate, row.actualEndDate)
},
{
prop: 'updateTime',
label: '最近更新',
minWidth: 180,
formatter: row => formatPersonalItemDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 240,
align: 'center',
fixed: 'right',
formatter: row => renderRowActions(row)
}
]
});
const { bool: operateVisible, setTrue: openOperateDialog, setFalse: closeOperateDialog } = useBoolean();
const { bool: detailVisible, setTrue: openDetailDialog } = useBoolean();
const {
bool: bindExecutionVisible,
setTrue: openBindExecutionDialog,
setFalse: closeBindExecutionDialog
} = useBoolean();
const { bool: statusActionVisible, setTrue: openStatusActionDialog, setFalse: closeStatusActionDialog } = useBoolean();
const operateType = ref<PersonalItemOperateType>('add');
const editingData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailDefaultTab = ref<DetailTab>('worklog');
const currentStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
const currentStatusItem = ref<Api.PersonalItem.PersonalItem | null>(null);
async function openDetail(row: Api.PersonalItem.PersonalItem, defaultTab: DetailTab = 'worklog') {
const { error, data: latestDetail } = await fetchGetPersonalItemDetail(row.id);
detailData.value = error || !latestDetail ? row : latestDetail;
detailDefaultTab.value = defaultTab;
openDetailDialog();
}
function openView(row: Api.PersonalItem.PersonalItem) {
operateType.value = 'view';
editingData.value = row;
openOperateDialog();
}
// function createLifecycleAction(
// fallback: {
// key: string;
// tooltip: string;
// icon: object;
// type: PersonalItemRowAction['type'];
// actionCode: string;
// },
// action: Api.PersonalItem.PersonalItemLifecycleAction | null
// ): PersonalItemRowAction {
// return {
// key: fallback.key,
// tooltip: action?.actionName ?? fallback.tooltip,
// icon: fallback.icon,
// type: fallback.type,
// disabled: !action,
// onClick: async () =>
// handleStatusAction(currentStatusItem.value!, {
// actionCode: action?.actionCode ?? fallback.actionCode,
// actionName: action?.actionName ?? fallback.tooltip,
// needReason: action?.needReason ?? false
// })
// };
// }
function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAction[] {
currentStatusItem.value = row;
const rawLifecycleActions = [...(row.availableActions ?? [])];
const pauseAction = rawLifecycleActions.find(action => action.actionCode === 'pause') ?? null;
const cancelAction = rawLifecycleActions.find(action => action.actionCode === 'cancel') ?? null;
const completeAction = rawLifecycleActions.find(action => action.actionCode === 'complete') ?? null;
const lifecycleActions = rawLifecycleActions
.filter(action => !['pause', 'cancel', 'complete'].includes(action.actionCode))
.sort(
(left, right) => (lifecycleActionOrder[left.actionCode] ?? 99) - (lifecycleActionOrder[right.actionCode] ?? 99)
)
.map(action => ({
key: `status-${action.actionCode}`,
tooltip: action.actionName,
icon: markRaw(lifecycleActionIconMap[action.actionCode] ?? IconMdiSync),
type: lifecycleActionTypeMap[action.actionCode] ?? 'primary',
onClick: async () => handleStatusAction(row, action)
}));
return [
{
key: 'worklog',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: async () => openDetail(row, 'worklog')
},
{
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: async () => {
operateType.value = 'edit';
editingData.value = row;
openOperateDialog();
}
},
{
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: async () => handleDelete(row)
},
{
key: 'status-pause',
tooltip: pauseAction?.actionName ?? '暂停',
icon: markRaw(IconMdiPause),
type: 'warning',
disabled: !pauseAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: pauseAction?.actionCode ?? 'pause',
actionName: pauseAction?.actionName ?? '暂停',
needReason: pauseAction?.needReason ?? false
})
},
// {
// key: 'status-cancel',
// tooltip: cancelAction?.actionName ?? '取消',
// icon: markRaw(IconMdiCloseCircleOutline),
// type: 'danger',
// disabled: !cancelAction,
// onClick: async () =>
// handleStatusAction(row, {
// actionCode: cancelAction?.actionCode ?? 'cancel',
// actionName: cancelAction?.actionName ?? '取消',
// needReason: cancelAction?.needReason ?? false
// })
// },
...lifecycleActions,
{
key: 'status-complete',
tooltip: completeAction?.actionName ?? '完成',
icon: markRaw(IconMdiCheckCircleOutline),
type: 'success',
disabled: !completeAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: completeAction?.actionCode ?? 'complete',
actionName: completeAction?.actionName ?? '完成',
needReason: completeAction?.needReason ?? false
})
}
];
}
function renderRowActions(row: Api.PersonalItem.PersonalItem) {
return (
<div class="personal-item-row-actions" onClick={event => event.stopPropagation()}>
{buildRowActions(row).map(action => {
const Icon = action.icon as any;
return (
<ElTooltip key={action.key} content={action.tooltip}>
<span class="inline-flex">
<ElButton
link
type={action.type}
class="personal-item-row-action-btn"
disabled={action.disabled}
onClick={event => {
event.stopPropagation();
if (action.disabled) {
return;
}
action.onClick();
}}
>
<Icon class="text-15px" />
</ElButton>
</span>
</ElTooltip>
);
})}
</div>
);
}
function openAdd() {
operateType.value = 'add';
editingData.value = null;
openOperateDialog();
}
function handleSelectionChange(rows: Api.PersonalItem.PersonalItem[]) {
checkedRowIds.value = rows.map(item => item.id);
}
function resolveReloadPageAfterRemove() {
const currentPage = searchParams.pageNo ?? 1;
if (currentPage > 1 && data.value.length > 0 && checkedRowIds.value.length >= data.value.length) {
return currentPage - 1;
}
return currentPage;
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
checkedRowIds.value = [];
await getDataByPage(page);
await nextTick();
tableRef.value?.clearSelection();
}
function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
closeOperateDialog();
reloadTable(searchParams.pageNo ?? 1);
}
function handleDetailChanged(latestItem: Api.PersonalItem.PersonalItem) {
detailData.value = latestItem;
const targetIndex = data.value.findIndex(item => item.id === latestItem.id);
if (targetIndex >= 0) {
data.value.splice(targetIndex, 1, latestItem);
}
}
function handleStatusAction(row: Api.PersonalItem.PersonalItem, action: Api.PersonalItem.PersonalItemLifecycleAction) {
currentStatusItem.value = row;
currentStatusAction.value = action;
openStatusActionDialog();
}
async function handleStatusActionSubmit(reason: string | null) {
if (!currentStatusItem.value || !currentStatusAction.value) {
return;
}
const { error } = await fetchChangePersonalItemStatus(currentStatusItem.value.id, {
actionCode: currentStatusAction.value.actionCode,
reason
});
if (error) {
return;
}
closeStatusActionDialog();
window.$message?.success(`${currentStatusAction.value.actionName}成功`);
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleDelete(row: Api.PersonalItem.PersonalItem) {
try {
await ElMessageBox.confirm(`确定删除个人事项“${row.taskTitle}”吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const { error } = await fetchDeletePersonalItem(row.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleBatchDelete() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
try {
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条个人事项吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBatchDeletePersonalItems({ ids: [...checkedRowIds.value] });
if (error) {
return;
}
window.$message?.success('批量删除成功');
await reloadTable(targetPage);
}
function handleOpenBindExecution() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
openBindExecutionDialog();
}
async function handleBindExecutionSubmit(payload: { executionId: string }) {
bindExecutionSubmitting.value = true;
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBindPersonalItemsToExecution({
ids: [...checkedRowIds.value],
executionId: payload.executionId
});
bindExecutionSubmitting.value = false;
if (error) {
return;
}
closeBindExecutionDialog();
window.$message?.success('批量关联执行成功');
await reloadTable(targetPage);
}
onActivated(() => {
searchParams.ownerId = currentUserId.value || undefined;
reloadTable(searchParams.pageNo ?? 1);
});
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<PersonalItemSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>个人事项</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain type="danger" :disabled="selectedCount === 0" @click="handleBatchDelete">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
批量删除
</ElButton>
<ElButton plain :disabled="selectedCount === 0" @click="handleOpenBindExecution">
<template #icon>
<icon-mdi-link-variant class="text-icon" />
</template>
批量关联执行
</ElButton>
<ElButton plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
ref="tableRef"
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-if="col.prop === 'taskTitle'" v-bind="col">
<template #default="{ row }">
<ElButton link type="primary" class="personal-item-title-link" @click.stop="openView(row)">
{{ row.taskTitle || '--' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn v-else v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<PersonalItemOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="handleSubmitted"
/>
<PersonalItemDetailDialog
v-model:visible="detailVisible"
:row-data="detailData"
:default-tab="detailDefaultTab"
@changed="handleDetailChanged"
/>
<PersonalItemBindExecutionDialog
v-model:visible="bindExecutionVisible"
:selected-count="selectedCount"
:submit-loading="bindExecutionSubmitting"
@submit="handleBindExecutionSubmit"
/>
<PersonalItemStatusActionDialog
v-model:visible="statusActionVisible"
:action="currentStatusAction"
@submit="handleStatusActionSubmit"
/>
</div>
</template>
<style scoped lang="scss">
.personal-item-row-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.personal-item-row-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.personal-item-row-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
:deep(.personal-item-title-link) {
max-width: 100%;
padding: 0;
vertical-align: baseline;
}
:deep(.personal-item-title-link > span) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { fetchGetPersonalItemExecutionOptions } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemBindExecutionDialog' });
interface Props {
selectedCount: number;
submitLoading?: boolean;
}
interface Emits {
(e: 'submit', payload: { executionId: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
submitLoading: false
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const loading = ref(false);
const executionOptions = ref<Api.PersonalItem.PersonalItemExecutionOption[]>([]);
const model = reactive({
executionId: ''
});
const rules = computed(
() =>
({
executionId: [createRequiredRule('请选择执行')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function getExecutionOptionLabel(option: Api.PersonalItem.PersonalItemExecutionOption) {
if (option.projectName?.trim()) {
return `${option.projectName} / ${option.executionName}`;
}
return option.executionName;
}
async function loadExecutionOptions() {
loading.value = true;
const { error, data } = await fetchGetPersonalItemExecutionOptions();
loading.value = false;
if (error || !data) {
executionOptions.value = [];
return;
}
executionOptions.value = data.map(item => ({ ...item }));
}
async function initDialog() {
model.executionId = '';
await loadExecutionOptions();
formRef.value?.clearValidate();
}
async function handleConfirm() {
await validate();
emit('submit', {
executionId: model.executionId
});
}
watch(
() => visible.value,
value => {
if (value) {
initDialog();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="批量关联执行"
preset="sm"
:loading="loading"
:confirm-loading="props.submitLoading"
@confirm="handleConfirm"
>
<ElAlert
:title="`已选中 ${props.selectedCount} 条个人事项,关联成功后这些事项会从当前列表移除。`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="执行" prop="executionId">
<ElSelect
v-model="model.executionId"
clearable
filterable
placeholder="请选择执行"
class="w-full"
:loading="loading"
>
<ElOption
v-for="option in executionOptions"
:key="option.executionId"
:label="getExecutionOptionLabel(option)"
:value="option.executionId"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCompletePersonalItem,
fetchCreatePersonalItemWorklog,
fetchDeletePersonalItemWorklog,
fetchGetPersonalItemDetail,
fetchGetPersonalItemWorklogPage,
fetchUpdatePersonalItemWorklog
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import TaskWorklogPanel from '@/views/project/project/execution/modules/task-worklog-panel.vue';
import {
formatPersonalItemDate,
formatPersonalItemOwnerName,
formatPersonalItemProgress,
getPersonalItemStatusLabel,
resolvePersonalItemStatusTagType
} from './personal-item-shared';
defineOptions({ name: 'PersonalItemDetailDialog' });
type TabName = 'worklog';
interface Props {
rowData?: Api.PersonalItem.PersonalItem | null;
defaultTab?: TabName;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
defaultTab: 'worklog'
});
const emit = defineEmits<{
changed: [item: Api.PersonalItem.PersonalItem];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const activeTab = ref<TabName>('worklog');
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const currentUserName = computed(
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
);
const COMPLETED_STATUS_CODE: Api.PersonalItem.PersonalItemStatusCode = 'completed';
const COMPLETE_ACTION_CODE = 'complete';
const ownerName = computed(() => {
if (!detailData.value) return '--';
const displayName = formatPersonalItemOwnerName(detailData.value);
if (displayName !== detailData.value.ownerId) {
return displayName;
}
return detailData.value.ownerId === currentUserId.value && currentUserName.value
? currentUserName.value
: displayName;
});
const statusName = computed(() => (detailData.value ? getPersonalItemStatusLabel(detailData.value.statusCode) : '--'));
const statusTagType = computed(() =>
detailData.value ? resolvePersonalItemStatusTagType(detailData.value.statusCode) : 'info'
);
const progressText = computed(() => formatPersonalItemProgress(detailData.value?.progressRate));
const plannedStartText = computed(() => formatPersonalItemDate(detailData.value?.plannedStartDate));
const plannedEndText = computed(() => formatPersonalItemDate(detailData.value?.plannedEndDate));
const actualStartText = computed(() => formatPersonalItemDate(detailData.value?.actualStartDate));
const actualEndText = computed(() => formatPersonalItemDate(detailData.value?.actualEndDate));
const totalHoursText = computed(() => {
const total = detailData.value?.totalSpentHours;
return `${typeof total === 'number' && Number.isFinite(total) ? total.toFixed(1) : '0.0'}h`;
});
const canSubmitWorklog = computed(() =>
Boolean(
detailData.value?.id &&
(detailData.value.statusCode === 'pending' ||
detailData.value.statusCode === 'active' ||
detailData.value.statusCode === 'completed')
)
);
function syncDetailFromPageRow() {
detailData.value = props.rowData ?? null;
}
async function refreshDetail() {
if (!detailData.value?.id) {
return;
}
const { error, data } = await fetchGetPersonalItemDetail(detailData.value.id);
if (!error && data) {
detailData.value = data;
}
}
function canPromptCompleteItem(item: Api.PersonalItem.PersonalItem) {
if (item.statusCode === COMPLETED_STATUS_CODE || item.terminal) {
return false;
}
return (
item.progressRate >= 100 && (item.availableActions ?? []).some(action => action.actionCode === COMPLETE_ACTION_CODE)
);
}
async function promptCompleteItemIfNeeded() {
if (!detailData.value || !canPromptCompleteItem(detailData.value)) {
return;
}
try {
await ElMessageBox.confirm('事项进度已达 100%,是否完成当前事项?', '完成确认', {
confirmButtonText: '完成事项',
cancelButtonText: '仅保留工时',
type: 'info'
});
} catch {
return;
}
const { error } = await fetchCompletePersonalItem(detailData.value.id);
if (!error) {
window.$message?.success('个人事项已完成');
await refreshDetail();
}
}
async function handleWorklogChanged() {
await refreshDetail();
await promptCompleteItemIfNeeded();
if (detailData.value) {
emit('changed', detailData.value);
}
}
function fetchPersonalWorklogPage(params: Api.Project.TaskWorklogSearchParams) {
return fetchGetPersonalItemWorklogPage(detailData.value!.id, params);
}
function createPersonalWorklog(data: Api.Project.SaveTaskWorklogParams) {
return fetchCreatePersonalItemWorklog(detailData.value!.id, data);
}
function updatePersonalWorklog(payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }) {
return fetchUpdatePersonalItemWorklog(detailData.value!.id, payload);
}
function deletePersonalWorklog(worklogId: string) {
return fetchDeletePersonalItemWorklog(detailData.value!.id, worklogId);
}
watch(
() => visible.value,
value => {
if (value) {
activeTab.value = props.defaultTab;
syncDetailFromPageRow();
}
}
);
watch(
() => props.rowData,
() => {
if (visible.value) {
syncDetailFromPageRow();
}
}
);
watch(
() => props.defaultTab,
value => {
if (visible.value) {
activeTab.value = value;
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="工作日志"
width="1100px"
max-body-height="78vh"
:show-footer="false"
:scrollbar="false"
>
<ElTabs v-model="activeTab" class="personal-item-detail-dialog__tabs">
<ElTabPane label="工作日志" name="worklog" lazy>
<div v-if="detailData" class="personal-item-worklog-content">
<div class="personal-item-worklog-content__cards">
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">负责人</span>
<span class="personal-item-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">当前状态</span>
<ElTag :type="statusTagType" size="small" effect="light" class="personal-item-worklog-content__card-tag">
{{ statusName }}
</ElTag>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">计划开始</span>
<span class="personal-item-worklog-content__card-value">{{ plannedStartText }}</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">计划结束</span>
<span class="personal-item-worklog-content__card-value">{{ plannedEndText }}</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">当前进度</span>
<span class="personal-item-worklog-content__card-value">{{ progressText }}</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">累计工时</span>
<span class="personal-item-worklog-content__card-value personal-item-worklog-content__card-value--accent">
{{ totalHoursText }}
</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">实际开始</span>
<span class="personal-item-worklog-content__card-value">{{ actualStartText }}</span>
</div>
<div class="personal-item-worklog-content__card">
<span class="personal-item-worklog-content__card-label">实际结束</span>
<span class="personal-item-worklog-content__card-value">{{ actualEndText }}</span>
</div>
</div>
<TaskWorklogPanel
project-id=""
execution-id=""
:task-id="detailData.id"
:task-owner-id="currentUserId"
:task-status-code="detailData.statusCode"
:task-progress-rate="detailData.progressRate"
:can-submit="canSubmitWorklog"
:active="activeTab === 'worklog' && visible"
:fetch-worklog-page="fetchPersonalWorklogPage"
:create-worklog="createPersonalWorklog"
:update-worklog="updatePersonalWorklog"
:delete-worklog="deletePersonalWorklog"
attachment-directory="personal-item-worklog"
create-success-message="工作日志新增成功"
update-success-message="工作日志修改成功"
delete-success-message="工作日志删除成功"
@changed="handleWorklogChanged"
/>
</div>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.personal-item-detail-dialog__tabs {
--el-tabs-header-height: 40px;
}
.personal-item-detail-dialog__tabs :deep(.el-tabs__content),
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
min-height: 640px;
}
.personal-item-worklog-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.personal-item-worklog-content__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.personal-item-worklog-content__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.personal-item-worklog-content__card-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.2;
}
.personal-item-worklog-content__card-value {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.personal-item-worklog-content__card-value--accent {
color: var(--el-color-primary);
}
.personal-item-worklog-content__card-tag {
align-self: flex-start;
}
</style>

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreatePersonalItem, fetchGetPersonalItemDetail, fetchUpdatePersonalItem } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { isEmptyRichText } from './personal-item-shared';
defineOptions({ name: 'PersonalItemOperateDialog' });
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface Props {
operateType: PersonalItemOperateType;
rowData?: Api.PersonalItem.PersonalItem | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || 'current-user');
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
const isEdit = computed(() => props.operateType === 'edit');
const isView = computed(() => props.operateType === 'view');
const detailLoading = ref(false);
const submitting = ref(false);
interface Model {
taskTitle: string;
type: string;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<Model>(createDefaultModel());
const title = computed(() => {
if (isView.value) {
return '个人事项详情';
}
return isEdit.value ? '编辑个人事项' : '新增个人事项';
});
function createDefaultModel(): Model {
return {
taskTitle: '',
type: '',
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
attachments: []
};
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
const rules = computed(
() =>
({
taskTitle: [
createRequiredRule('请输入事项标题'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入事项标题'));
return;
}
callback();
},
trigger: 'blur'
}
],
type: [createRequiredRule('请选择事项类型')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function initModel() {
detailLoading.value = true;
Object.assign(model, createDefaultModel());
if ((isEdit.value || isView.value) && props.rowData) {
const { error, data } = await fetchGetPersonalItemDetail(props.rowData.id);
if (!error && data) {
model.taskTitle = data.taskTitle;
model.type = data.type;
model.plannedStartDate = data.plannedStartDate;
model.plannedEndDate = data.plannedEndDate;
model.taskDesc = data.taskDesc;
model.attachments = data.attachments ? [...data.attachments] : [];
}
}
detailLoading.value = false;
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (isView.value) {
visible.value = false;
return;
}
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const payload: Api.PersonalItem.SavePersonalItemParams = {
taskTitle: model.taskTitle.trim(),
type: model.type,
ownerId: currentUserId.value,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
attachments: [...model.attachments]
};
submitting.value = true;
const result =
isEdit.value && props.rowData
? await fetchUpdatePersonalItem({ id: props.rowData.id, ...payload })
: await fetchCreatePersonalItem(payload);
submitting.value = false;
if (result.error) {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success(isEdit.value ? '个人事项修改成功' : '个人事项创建成功');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
value => {
if (value) {
initModel();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
width="1100px"
:loading="detailLoading"
:confirm-loading="submitting"
:show-footer="!isView"
max-body-height="78vh"
@confirm="handleSubmit"
>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="personal-item-operate-dialog__form"
>
<div class="personal-item-operate-dialog__grid">
<div ref="leftColRef" class="personal-item-operate-dialog__col-left">
<BusinessFormSection title="事项信息">
<ElFormItem label="事项标题" prop="taskTitle">
<ElInput
v-model="model.taskTitle"
:clearable="!isView"
:disabled="isView"
maxlength="300"
placeholder="请输入事项标题"
/>
</ElFormItem>
<ElFormItem label="事项类型" prop="type">
<DictSelect
v-model="model.type"
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
:clearable="!isView"
:disabled="isView"
placeholder="请选择事项类型"
/>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划开始日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划结束日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="personal-item-operate-dialog__col-right">
<BusinessFormSection title="事项说明">
<ElFormItem class="personal-item-operate-dialog__desc-item" prop="taskDesc">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.taskDesc"
:height="editorHeight"
:disabled="isView"
upload-directory="personal-item"
placeholder="请输入事项说明"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="personal-item-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="personal-item"
:disabled="isView"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.personal-item-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.personal-item-operate-dialog__col-left,
.personal-item-operate-dialog__col-right {
min-width: 0;
}
.personal-item-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.personal-item-operate-dialog__desc-item,
.personal-item-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.personal-item-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
:deep(.personal-item-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { fetchGetObjectStatusModelPage } from '@/service/api';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { personalItemStatusOptions } from './personal-item-shared';
defineOptions({ name: 'PersonalItemSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.PersonalItem.PersonalItemSearchParams>('model', {
required: true
});
const searchModel = reactive<{
keyword: string;
statusCode?: Api.PersonalItem.PersonalItemStatusCode;
}>({
keyword: '',
statusCode: undefined
});
let syncingFromSource = false;
const statusOptions = ref<Array<{ label: string; value: string }>>([...personalItemStatusOptions]);
watch(
() => [model.value.keyword, model.value.statusCode] as const,
([keyword, statusCode]) => {
syncingFromSource = true;
searchModel.keyword = keyword ?? '';
searchModel.statusCode = statusCode;
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() => [searchModel.keyword, searchModel.statusCode] as const,
([keyword, statusCode]) => {
if (syncingFromSource) {
return;
}
model.value.keyword = keyword.trim() || undefined;
model.value.statusCode = statusCode;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入标题或说明'
},
{
key: 'statusCode',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: statusOptions.value
}
]);
async function loadStatusOptions() {
const { error, data } = await fetchGetObjectStatusModelPage({
pageNo: 1,
pageSize: 100,
objectType: 'task',
status: 0,
initialFlag: undefined,
terminalFlag: undefined,
keyword: undefined
});
if (error || !data?.list?.length) {
statusOptions.value = [...personalItemStatusOptions];
return;
}
statusOptions.value = data.list
.slice()
.sort((left, right) => left.sort - right.sort)
.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
onMounted(() => {
loadStatusOptions();
});
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="3" @reset="handleReset" @search="handleSearch" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,105 @@
import dayjs from 'dayjs';
import { getPersonalItemStatusTagType } from '@/constants/status-tag';
export const personalItemStatusOptions = [
{ label: '待处理', value: 'pending' as const },
{ label: '进行中', value: 'active' as const },
{ label: '已完成', value: 'completed' as const }
];
const personalItemStatusLabelMap: Record<Api.PersonalItem.PersonalItemStatusCode, string> = {
pending: '待开始',
active: '进行中',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消'
};
export function getPersonalItemStatusLabel(statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined) {
if (!statusCode) {
return '--';
}
return personalItemStatusLabelMap[statusCode] || '--';
}
export function resolvePersonalItemStatusTagType(
statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined
) {
return getPersonalItemStatusTagType(statusCode);
}
export function formatPersonalItemDate(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD');
}
export function formatPersonalItemDateTime(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD HH:mm:ss');
}
export function formatPersonalItemProgress(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '0%';
}
const normalized = Math.round(Math.min(100, Math.max(0, value)) * 100) / 100;
return `${normalized}%`;
}
export function formatPersonalItemName(value: string | null | undefined) {
return value?.trim() || '--';
}
export function formatPersonalItemOwnerName(
item: Pick<Api.PersonalItem.PersonalItem, 'ownerNickname' | 'ownerName' | 'ownerId'>
) {
return item.ownerNickname?.trim() || item.ownerName?.trim() || item.ownerId || '--';
}
export function formatPersonalItemDateRange(start: string | null | undefined, end: string | null | undefined) {
const startText = formatPersonalItemDate(start);
const endText = formatPersonalItemDate(end);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText} ~ ${endText}`;
}
export function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemStatusActionDialog' });
interface Props {
action: Api.PersonalItem.PersonalItemLifecycleAction | null;
}
interface Emits {
(e: 'submit', reason: string | null): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive({
reason: ''
});
const rules = computed(
() =>
({
reason: props.action?.needReason ? [createRequiredRule('请输入动作原因')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
emit('submit', model.reason.trim() || null);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.reason = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="action?.actionName || '状态变更'" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="动作原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="action?.needReason ? '请输入动作原因' : '可选填写动作原因'"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的月报" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { userGenderRecord } from '@/constants/business';
import { fetchGetMyProfileDetail, fetchUpdateMyAvatar } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import ProfileInfoDialog from './modules/profile-info-dialog.vue';
import ProfilePasswordDialog from './modules/profile-password-dialog.vue';
import { formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
defineOptions({ name: 'MyProfile' });
const authStore = useAuthStore();
const appStore = useAppStore();
const loading = ref(false);
const avatarSubmitting = ref(false);
const profile = ref<Api.Auth.MyProfileDetail | null>(null);
const profileInfoVisible = ref(false);
const passwordVisible = ref(false);
const avatarInputRef = ref<HTMLInputElement | null>(null);
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const descriptionColumns = computed(() => (appStore.isMobile ? 1 : 2));
const displayName = computed(() => profile.value?.nickname?.trim() || profile.value?.username || '--');
const displayUsername = computed(() => profile.value?.username?.trim() || '--');
const companyText = computed(() => profile.value?.company?.trim() || '--');
const deptText = computed(() => profile.value?.dept?.name?.trim() || profile.value?.deptName?.trim() || '--');
const positionText = computed(
() => profile.value?.position?.name?.trim() || profile.value?.positionName?.trim() || '--'
);
const mobileText = computed(() => profile.value?.mobile?.trim() || '--');
const emailText = computed(() => profile.value?.email?.trim() || '--');
const genderText = computed(() => {
const value = profile.value?.sex;
if (value === null || value === undefined) {
return '--';
}
return $t(userGenderRecord[value]);
});
const roleLabels = computed(() => {
const roles = profile.value?.roles ?? [];
if (roles.length === 0) {
return [];
}
return resolveProfileRoleLabels(roles);
});
function getAvatarText() {
const name = displayName.value;
return name === '--' ? 'CN' : name.slice(0, 1).toUpperCase();
}
async function loadProfile() {
const userId = authStore.userInfo.userId;
if (!userId) {
profile.value = null;
return;
}
const { data, error } = await fetchGetMyProfileDetail({ userId });
if (!error) {
profile.value = data;
}
}
async function initPage() {
loading.value = true;
await authStore.initUserInfo();
await loadProfile();
loading.value = false;
}
function triggerAvatarSelect() {
if (!profile.value || avatarSubmitting.value) {
return;
}
avatarInputRef.value?.click();
}
async function handleAvatarChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !profile.value) {
return;
}
if (!file.type.startsWith('image/')) {
window.$message?.error('请上传图片文件');
return;
}
if (file.size > MAX_AVATAR_SIZE) {
window.$message?.error('头像图片大小不能超过 5MB');
return;
}
avatarSubmitting.value = true;
const updateResult = await fetchUpdateMyAvatar(file);
avatarSubmitting.value = false;
if (updateResult.error) {
return;
}
window.$message?.success('头像更新成功');
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
}
async function handleProfileSubmitted() {
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
}
onMounted(() => {
initPage();
});
onActivated(() => {
initPage();
});
</script>
<template>
<div v-loading="loading" class="my-profile-page">
<template v-if="profile">
<ElCard class="my-profile-hero-card" shadow="never">
<div class="my-profile-hero">
<div class="my-profile-hero__identity">
<button
class="my-profile-hero__avatar-button"
type="button"
:disabled="avatarSubmitting"
@click="triggerAvatarSelect"
>
<ElAvatar v-if="profile.avatar" :src="profile.avatar" :size="88" class="my-profile-hero__avatar" />
<div v-else class="my-profile-hero__avatar-fallback">{{ getAvatarText() }}</div>
<div class="my-profile-hero__avatar-mask">
<span>{{ avatarSubmitting ? '上传中...' : '更换头像' }}</span>
</div>
</button>
<input
ref="avatarInputRef"
class="my-profile-hero__avatar-input"
type="file"
accept="image/*"
@change="handleAvatarChange"
/>
<div class="my-profile-hero__summary">
<div class="my-profile-hero__title-row">
<h1 class="my-profile-hero__title">{{ displayName }}</h1>
<ElTag type="info" effect="plain">个人中心</ElTag>
</div>
<p class="my-profile-hero__subtitle">@{{ displayUsername }}</p>
<div class="my-profile-hero__meta">
<ElTag effect="plain">{{ companyText }}</ElTag>
<ElTag effect="plain">{{ deptText }}</ElTag>
<ElTag effect="plain">{{ positionText }}</ElTag>
</div>
</div>
</div>
<div class="my-profile-hero__actions">
<ElButton type="primary" @click="profileInfoVisible = true">编辑基本信息</ElButton>
<ElButton @click="passwordVisible = true">修改密码</ElButton>
</div>
</div>
</ElCard>
<div class="my-profile-content">
<ElCard shadow="never">
<template #header>
<div class="my-profile-card__header">
<span class="my-profile-card__title">基本资料</span>
</div>
</template>
<ElDescriptions :column="descriptionColumns" border>
<ElDescriptionsItem label="用户名">{{ displayUsername }}</ElDescriptionsItem>
<ElDescriptionsItem label="名称">{{ displayName }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ mobileText }}</ElDescriptionsItem>
<ElDescriptionsItem label="邮箱">{{ emailText }}</ElDescriptionsItem>
<ElDescriptionsItem label="性别">{{ genderText }}</ElDescriptionsItem>
<ElDescriptionsItem label="所属公司">{{ companyText }}</ElDescriptionsItem>
<ElDescriptionsItem label="所属部门">{{ deptText }}</ElDescriptionsItem>
<ElDescriptionsItem label="所属岗位">{{ positionText }}</ElDescriptionsItem>
<ElDescriptionsItem label="角色" :span="descriptionColumns">
<div v-if="roleLabels.length" class="my-profile-role-list">
<ElTag v-for="roleLabel in roleLabels" :key="roleLabel" type="primary" effect="plain">
{{ roleLabel }}
</ElTag>
</div>
<span v-else>--</span>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="my-profile-card__header">
<span class="my-profile-card__title">登录信息</span>
</div>
</template>
<ElDescriptions :column="descriptionColumns" border>
<ElDescriptionsItem label="最近登录 IP">{{ profile.loginIp?.trim() || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近登录时间">{{ formatProfileDateTime(profile.loginDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="账号创建时间" :span="descriptionColumns">
{{ formatProfileDateTime(profile.createTime) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</template>
<ElEmpty v-else description="未获取到个人信息" />
<ProfileInfoDialog v-model:visible="profileInfoVisible" :profile="profile" @submitted="handleProfileSubmitted" />
<ProfilePasswordDialog v-model:visible="passwordVisible" :username="profile?.username" />
</div>
</template>
<style scoped>
.my-profile-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.my-profile-hero-card {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 12%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 10%), transparent 26%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.my-profile-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
}
.my-profile-hero__identity {
display: flex;
min-width: 0;
align-items: center;
gap: 18px;
}
.my-profile-hero__avatar-button {
position: relative;
display: inline-flex;
width: 88px;
height: 88px;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
padding: 0;
overflow: hidden;
background: transparent;
cursor: pointer;
}
.my-profile-hero__avatar-button:disabled {
cursor: not-allowed;
}
.my-profile-hero__avatar,
.my-profile-hero__avatar-fallback {
width: 88px;
height: 88px;
}
.my-profile-hero__avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: linear-gradient(135deg, rgb(14 116 144 / 92%), rgb(15 118 110 / 84%));
color: #fff;
font-size: 28px;
font-weight: 700;
}
.my-profile-hero__avatar-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgb(15 23 42 / 52%);
color: #fff;
font-size: 12px;
font-weight: 600;
opacity: 0;
transition: opacity 0.2s ease;
}
.my-profile-hero__avatar-button:hover .my-profile-hero__avatar-mask,
.my-profile-hero__avatar-button:focus-visible .my-profile-hero__avatar-mask {
opacity: 1;
}
.my-profile-hero__avatar-input {
display: none;
}
.my-profile-hero__summary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
}
.my-profile-hero__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.my-profile-hero__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.15;
letter-spacing: -0.02em;
}
.my-profile-hero__subtitle {
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 14px;
}
.my-profile-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.my-profile-hero__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.my-profile-content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.my-profile-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.my-profile-card__title {
color: rgb(15 23 42 / 98%);
font-size: 15px;
font-weight: 700;
}
.my-profile-role-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (width <= 960px) {
.my-profile-hero {
grid-template-columns: 1fr;
}
.my-profile-hero__actions {
justify-content: flex-start;
}
.my-profile-content {
grid-template-columns: 1fr;
}
}
@media (width <= 640px) {
.my-profile-hero__identity {
flex-direction: column;
align-items: flex-start;
}
.my-profile-hero__title {
font-size: 24px;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { userGenderOptions } from '@/constants/business';
import { fetchUpdateMyProfile } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { translateOptions } from '@/utils/common';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { buildProfileUpdatePayload } from './profile-model';
defineOptions({ name: 'ProfileInfoDialog' });
interface Props {
profile?: Api.Auth.MyProfileDetail | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule, patternRules } = useFormRules();
const submitting = ref(false);
const genderOptions = computed(() =>
translateOptions(userGenderOptions).map(item => ({
...item,
value: Number(item.value) as Api.SystemManage.UserGender
}))
);
const model = ref<Api.Auth.UpdateMyProfileParams>({
nickname: '',
email: '',
mobile: '',
sex: 1,
avatar: ''
});
const rules = computed<Record<string, App.Global.FormRule[]>>(() => ({
nickname: [createRequiredRule('请输入昵称')],
mobile: model.value.mobile?.trim() ? [patternRules.phone] : [],
email: model.value.email?.trim() ? [patternRules.email] : []
}));
function initModel() {
model.value = {
nickname: props.profile?.nickname ?? '',
email: props.profile?.email ?? '',
mobile: props.profile?.mobile ?? '',
sex: props.profile?.sex ?? 1,
avatar: props.profile?.avatar ?? ''
};
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
if (!props.profile) {
return;
}
await validate();
submitting.value = true;
const { error } = await fetchUpdateMyProfile(buildProfileUpdatePayload(model.value));
submitting.value = false;
if (error) {
return;
}
window.$message?.success('个人信息更新成功');
closeDialog();
emit('submitted');
}
watch(visible, async value => {
if (!value) {
return;
}
initModel();
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="编辑基本信息"
preset="sm"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem label="昵称" prop="nickname">
<ElInput v-model="model.nickname" maxlength="30" placeholder="请输入昵称" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="手机号" prop="mobile">
<ElInput v-model="model.mobile" maxlength="20" placeholder="请输入手机号" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="model.email" maxlength="100" placeholder="请输入邮箱" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="性别" prop="sex">
<ElSelect v-model="model.sex" placeholder="请选择性别">
<ElOption v-for="{ label, value } in genderOptions" :key="value" :label="label" :value="value" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,27 @@
import dayjs from 'dayjs';
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
export function formatProfileDateTime(value?: string | number | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
export function resolveProfileRoleLabels(roles: Api.SystemManage.RoleSimple[]) {
return roles.map(role => role.name?.trim() || role.code || role.id);
}
export function buildProfileUpdatePayload(form: Api.Auth.UpdateMyProfileParams): Api.Auth.UpdateMyProfileParams {
return {
nickname: getNullableText(form.nickname),
email: getNullableText(form.email),
mobile: getNullableText(form.mobile),
sex: form.sex ?? null,
avatar: getNullableText(form.avatar)
};
}

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchUpdateMyPassword } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProfilePasswordDialog' });
interface Props {
username?: string | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const { formRef, validate } = useForm();
const { createRequiredRule, createConfirmPwdRule, patternRules } = useFormRules();
const submitting = ref(false);
const model = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
});
const confirmDisabled = computed(() => {
return (
submitting.value ||
!model.value.oldPassword.trim() ||
!model.value.newPassword.trim() ||
!model.value.confirmPassword.trim()
);
});
const rules = computed<Record<string, App.Global.FormRule[]>>(() => ({
oldPassword: [createRequiredRule('请输入旧密码')],
newPassword: [
createRequiredRule('请输入新密码'),
patternRules.pwd,
{
asyncValidator: (_rule, value: string) => {
if (value.trim() !== '' && value === model.value.oldPassword) {
return Promise.reject(new Error('新密码不能与旧密码相同'));
}
return Promise.resolve();
},
trigger: 'change'
}
],
confirmPassword: createConfirmPwdRule(model.value.newPassword)
}));
const displayUsername = computed(() => props.username?.trim() || '--');
function initModel() {
model.value.oldPassword = '';
model.value.newPassword = '';
model.value.confirmPassword = '';
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
if (confirmDisabled.value) {
return;
}
await validate();
submitting.value = true;
const { error } = await fetchUpdateMyPassword({
oldPassword: model.value.oldPassword.trim(),
newPassword: model.value.newPassword.trim()
});
submitting.value = false;
if (error) {
return;
}
window.$message?.success('密码修改成功,请重新登录');
closeDialog();
emit('submitted');
await authStore.resetStore();
}
watch(visible, async value => {
if (!value) {
return;
}
initModel();
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="修改密码"
preset="sm"
:confirm-loading="submitting"
:confirm-disabled="confirmDisabled"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
<input class="business-form-autofill-guard" type="text" name="fake-username" autocomplete="username" />
<input class="business-form-autofill-guard" type="password" name="fake-password" autocomplete="new-password" />
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem label="用户名">
<ElInput :model-value="displayUsername" disabled />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElAlert title="密码修改后会退出当前登录态,请使用新密码重新登录。" type="info" :closable="false" show-icon />
</ElCol>
<ElCol :span="24">
<ElFormItem label="旧密码" prop="oldPassword">
<ElInput
v-model="model.oldPassword"
show-password
autocomplete="current-password"
placeholder="请输入旧密码"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="新密码" prop="newPassword">
<ElInput v-model="model.newPassword" show-password autocomplete="new-password" placeholder="请输入新密码" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="确认新密码" prop="confirmPassword">
<ElInput
v-model="model.confirmPassword"
show-password
autocomplete="new-password"
placeholder="请再次输入新密码"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.business-form-autofill-guard {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的周报" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="待我审批" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { useThemeStore } from '@/store/modules/theme';
defineOptions({ name: 'MarkdownPage' });
const theme = useThemeStore();
const vditor = ref<Vditor>();
const domRef = ref<HTMLElement>();
function renderVditor() {
if (!domRef.value) return;
vditor.value = new Vditor(domRef.value, {
minHeight: 400,
theme: theme.darkMode ? 'dark' : 'classic',
icon: 'material',
cache: { enable: false }
});
}
const stopHandle = watch(
() => theme.darkMode,
newValue => {
const themeMode = newValue ? 'dark' : 'classic';
vditor.value?.setTheme(themeMode);
}
);
onMounted(() => {
renderVditor();
});
onUnmounted(() => {
stopHandle();
});
</script>
<template>
<div class="h-full">
<ElCard header="markdown插件" class="card-wrapper">
<div ref="domRef"></div>
<template #footer>
<GithubLink link="https://github.com/Vanessa219/vditor" />
</template>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
defineOptions({ name: 'QuillPage' });
const value = ref('<p>hello&nbsp;<strong>wangEditor v5</strong></p>');
</script>
<template>
<div class="h-full">
<ElCard header="富文本插件" class="card-wrapper">
<BusinessRichTextEditor v-model="value" :height="360" upload-directory="demo" />
<template #footer>
<GithubLink link="https://github.com/wangeditor-next/wangEditor-next" />
</template>
</ElCard>
</div>
</template>

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