Compare commits
24 Commits
71da2d507e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 030dc737fc | |||
| 80f028bcb9 | |||
| 5061eced32 | |||
| 6896a86130 | |||
| 0652a24c5e | |||
| d53a8dfae5 | |||
| 2e369b23a9 | |||
| b72ad00912 | |||
| 7cc29e0a35 | |||
| 39458386ae | |||
| acef4418d8 | |||
| 9d84b1aae0 | |||
| d3d0830820 | |||
| b2da882b31 | |||
| 4ed4b537ad | |||
| 3988eaf910 | |||
| e9214137c1 | |||
| 13b74cfe97 | |||
|
|
ab882e085b | ||
| 62859bfc38 | |||
| ba328e02bb | |||
|
|
28d597d91e | ||
|
|
fe29fde564 | ||
|
|
7d578ab271 |
4
.env
4
.env
@@ -2,9 +2,9 @@
|
|||||||
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
|
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
|
||||||
VITE_BASE_URL=/
|
VITE_BASE_URL=/
|
||||||
|
|
||||||
VITE_APP_TITLE=研发内部管理系统
|
VITE_APP_TITLE=研发管理系统
|
||||||
|
|
||||||
VITE_APP_DESC=Frontend application for 灿能研发内部管理系统
|
VITE_APP_DESC=Frontend application for 灿能研发管理系统
|
||||||
|
|
||||||
# 图标名称前缀
|
# 图标名称前缀
|
||||||
VITE_ICON_PREFIX=icon
|
VITE_ICON_PREFIX=icon
|
||||||
|
|||||||
2
.trae/rules/vue-need.md
Normal file
2
.trae/rules/vue-need.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
1. 每次开发新功能、编写代码时都添加好相应的注释。
|
||||||
|
2. 所有的vue文件编码必须是UTF-8的。
|
||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -415,3 +415,17 @@ pnpm preview # preview server (9725)
|
|||||||
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
||||||
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
||||||
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
|
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `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` 文档不主动改写**,等用户明确要求再转。
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -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
|
|
||||||
```
|
|
||||||
@@ -126,29 +126,46 @@ export function setupElegantRouter() {
|
|||||||
order: 0,
|
order: 0,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
'personal-center_my-weekly': {
|
'personal-center_my-item': {
|
||||||
icon: 'mdi:calendar-week-outline',
|
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||||
order: 1,
|
order: 1,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
'personal-center_my-monthly': {
|
'personal-center_work-report': {
|
||||||
icon: 'mdi:calendar-month-outline',
|
icon: 'mdi:file-chart-outline',
|
||||||
order: 2,
|
order: 3,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
|
'personal-center_work-report_weekly': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
|
'personal-center_work-report_monthly': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
|
'personal-center_work-report_project': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
'personal-center_my-performance': {
|
'personal-center_my-performance': {
|
||||||
icon: 'mdi:trophy-outline',
|
icon: 'mdi:trophy-outline',
|
||||||
order: 3,
|
order: 4,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
'personal-center_my-application': {
|
'personal-center_my-application': {
|
||||||
icon: 'mdi:file-document-outline',
|
icon: 'mdi:file-document-outline',
|
||||||
order: 4,
|
order: 5,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_overtime-application': {
|
||||||
|
icon: 'mdi:clock-plus-outline',
|
||||||
|
order: 6,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
'personal-center_pending-approval': {
|
'personal-center_pending-approval': {
|
||||||
icon: 'mdi:check-decagram-outline',
|
icon: 'mdi:check-decagram-outline',
|
||||||
order: 5,
|
order: 7,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
|
|||||||
@@ -1,46 +1,13 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-13T10:54:08.684Z",
|
"generatedAt": "2026-06-05T03:08:01.803Z",
|
||||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||||
"rules": {
|
"rules": {
|
||||||
"directoryComponent": "layout.base",
|
"directoryComponent": "layout.base",
|
||||||
"pageComponentPattern": "view.<routeName>",
|
"pageComponentPattern": "view.<routeName>",
|
||||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||||
},
|
},
|
||||||
"total": 21,
|
"total": 22,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
|
||||||
"name": "workbench",
|
|
||||||
"path": "/workbench",
|
|
||||||
"component": "layout.base$view.workbench",
|
|
||||||
"title": "workbench",
|
|
||||||
"routeTitle": "workbench",
|
|
||||||
"i18nKey": "route.workbench",
|
|
||||||
"icon": "mdi:view-dashboard-outline",
|
|
||||||
"localIcon": null,
|
|
||||||
"order": 1,
|
|
||||||
"hideInMenu": false,
|
|
||||||
"keepAlive": true,
|
|
||||||
"activeMenu": null,
|
|
||||||
"multiTab": false,
|
|
||||||
"fixedIndexInTab": null,
|
|
||||||
"redirect": null,
|
|
||||||
"props": null,
|
|
||||||
"meta": {
|
|
||||||
"title": "workbench",
|
|
||||||
"i18nKey": "route.workbench",
|
|
||||||
"icon": "mdi:view-dashboard-outline",
|
|
||||||
"localIcon": null,
|
|
||||||
"order": 1,
|
|
||||||
"keepAlive": true,
|
|
||||||
"hideInMenu": false,
|
|
||||||
"activeMenu": null,
|
|
||||||
"multiTab": false,
|
|
||||||
"fixedIndexInTab": null
|
|
||||||
},
|
|
||||||
"parentName": null,
|
|
||||||
"pageType": "single",
|
|
||||||
"source": "generated"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "product_list",
|
"name": "product_list",
|
||||||
"path": "/product/list",
|
"path": "/product/list",
|
||||||
@@ -111,7 +78,7 @@
|
|||||||
"name": "ticket_my-submitted",
|
"name": "ticket_my-submitted",
|
||||||
"path": "/ticket/my-submitted",
|
"path": "/ticket/my-submitted",
|
||||||
"component": "view.ticket_my-submitted",
|
"component": "view.ticket_my-submitted",
|
||||||
"title": "ticket_my-submitted",
|
"title": "我提交的工单",
|
||||||
"routeTitle": "ticket_my-submitted",
|
"routeTitle": "ticket_my-submitted",
|
||||||
"i18nKey": "route.ticket_my-submitted",
|
"i18nKey": "route.ticket_my-submitted",
|
||||||
"icon": "mdi:upload-outline",
|
"icon": "mdi:upload-outline",
|
||||||
@@ -125,7 +92,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "ticket_my-submitted",
|
"title": "我提交的工单",
|
||||||
"i18nKey": "route.ticket_my-submitted",
|
"i18nKey": "route.ticket_my-submitted",
|
||||||
"icon": "mdi:upload-outline",
|
"icon": "mdi:upload-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -144,7 +111,7 @@
|
|||||||
"name": "ticket_my-pending",
|
"name": "ticket_my-pending",
|
||||||
"path": "/ticket/my-pending",
|
"path": "/ticket/my-pending",
|
||||||
"component": "view.ticket_my-pending",
|
"component": "view.ticket_my-pending",
|
||||||
"title": "ticket_my-pending",
|
"title": "待我处理的工单",
|
||||||
"routeTitle": "ticket_my-pending",
|
"routeTitle": "ticket_my-pending",
|
||||||
"i18nKey": "route.ticket_my-pending",
|
"i18nKey": "route.ticket_my-pending",
|
||||||
"icon": "mdi:inbox-arrow-down-outline",
|
"icon": "mdi:inbox-arrow-down-outline",
|
||||||
@@ -158,7 +125,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "ticket_my-pending",
|
"title": "待我处理的工单",
|
||||||
"i18nKey": "route.ticket_my-pending",
|
"i18nKey": "route.ticket_my-pending",
|
||||||
"icon": "mdi:inbox-arrow-down-outline",
|
"icon": "mdi:inbox-arrow-down-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -177,7 +144,7 @@
|
|||||||
"name": "metrics_project-progress",
|
"name": "metrics_project-progress",
|
||||||
"path": "/metrics/project-progress",
|
"path": "/metrics/project-progress",
|
||||||
"component": "view.metrics_project-progress",
|
"component": "view.metrics_project-progress",
|
||||||
"title": "metrics_project-progress",
|
"title": "项目进度",
|
||||||
"routeTitle": "metrics_project-progress",
|
"routeTitle": "metrics_project-progress",
|
||||||
"i18nKey": "route.metrics_project-progress",
|
"i18nKey": "route.metrics_project-progress",
|
||||||
"icon": "mdi:progress-clock",
|
"icon": "mdi:progress-clock",
|
||||||
@@ -191,7 +158,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "metrics_project-progress",
|
"title": "项目进度",
|
||||||
"i18nKey": "route.metrics_project-progress",
|
"i18nKey": "route.metrics_project-progress",
|
||||||
"icon": "mdi:progress-clock",
|
"icon": "mdi:progress-clock",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -210,7 +177,7 @@
|
|||||||
"name": "metrics_member-efficiency",
|
"name": "metrics_member-efficiency",
|
||||||
"path": "/metrics/member-efficiency",
|
"path": "/metrics/member-efficiency",
|
||||||
"component": "view.metrics_member-efficiency",
|
"component": "view.metrics_member-efficiency",
|
||||||
"title": "metrics_member-efficiency",
|
"title": "员工能效",
|
||||||
"routeTitle": "metrics_member-efficiency",
|
"routeTitle": "metrics_member-efficiency",
|
||||||
"i18nKey": "route.metrics_member-efficiency",
|
"i18nKey": "route.metrics_member-efficiency",
|
||||||
"icon": "mdi:account-multiple-check-outline",
|
"icon": "mdi:account-multiple-check-outline",
|
||||||
@@ -224,7 +191,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "metrics_member-efficiency",
|
"title": "员工能效",
|
||||||
"i18nKey": "route.metrics_member-efficiency",
|
"i18nKey": "route.metrics_member-efficiency",
|
||||||
"icon": "mdi:account-multiple-check-outline",
|
"icon": "mdi:account-multiple-check-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -243,7 +210,7 @@
|
|||||||
"name": "metrics_worktime",
|
"name": "metrics_worktime",
|
||||||
"path": "/metrics/worktime",
|
"path": "/metrics/worktime",
|
||||||
"component": "view.metrics_worktime",
|
"component": "view.metrics_worktime",
|
||||||
"title": "metrics_worktime",
|
"title": "工时统计",
|
||||||
"routeTitle": "metrics_worktime",
|
"routeTitle": "metrics_worktime",
|
||||||
"i18nKey": "route.metrics_worktime",
|
"i18nKey": "route.metrics_worktime",
|
||||||
"icon": "mdi:clock-time-five-outline",
|
"icon": "mdi:clock-time-five-outline",
|
||||||
@@ -257,7 +224,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "metrics_worktime",
|
"title": "工时统计",
|
||||||
"i18nKey": "route.metrics_worktime",
|
"i18nKey": "route.metrics_worktime",
|
||||||
"icon": "mdi:clock-time-five-outline",
|
"icon": "mdi:clock-time-five-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -273,13 +240,46 @@
|
|||||||
"source": "generated"
|
"source": "generated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "personal-center_my-weekly",
|
"name": "personal-center_my-profile",
|
||||||
"path": "/personal-center/my-weekly",
|
"path": "/personal-center/my-profile",
|
||||||
"component": "view.personal-center_my-weekly",
|
"component": "view.personal-center_my-profile",
|
||||||
"title": "personal-center_my-weekly",
|
"title": "个人信息",
|
||||||
"routeTitle": "personal-center_my-weekly",
|
"routeTitle": "personal-center_my-profile",
|
||||||
"i18nKey": "route.personal-center_my-weekly",
|
"i18nKey": "route.personal-center_my-profile",
|
||||||
"icon": "mdi:calendar-week-outline",
|
"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,
|
"localIcon": null,
|
||||||
"order": 1,
|
"order": 1,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
@@ -290,9 +290,9 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "personal-center_my-weekly",
|
"title": "我的事项",
|
||||||
"i18nKey": "route.personal-center_my-weekly",
|
"i18nKey": "route.personal-center_my-item",
|
||||||
"icon": "mdi:calendar-week-outline",
|
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 1,
|
"order": 1,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
@@ -306,15 +306,15 @@
|
|||||||
"source": "generated"
|
"source": "generated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "personal-center_my-monthly",
|
"name": "personal-center_work-report",
|
||||||
"path": "/personal-center/my-monthly",
|
"path": "/personal-center/work-report",
|
||||||
"component": "view.personal-center_my-monthly",
|
"component": "view.personal-center_work-report",
|
||||||
"title": "personal-center_my-monthly",
|
"title": "工作报告",
|
||||||
"routeTitle": "personal-center_my-monthly",
|
"routeTitle": "personal-center_work-report",
|
||||||
"i18nKey": "route.personal-center_my-monthly",
|
"i18nKey": "route.personal-center_work-report",
|
||||||
"icon": "mdi:calendar-month-outline",
|
"icon": "mdi:file-chart-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 2,
|
"order": 3,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "personal-center_my-monthly",
|
"title": "工作报告",
|
||||||
"i18nKey": "route.personal-center_my-monthly",
|
"i18nKey": "route.personal-center_work-report",
|
||||||
"icon": "mdi:calendar-month-outline",
|
"icon": "mdi:file-chart-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 2,
|
"order": 3,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -342,12 +342,12 @@
|
|||||||
"name": "personal-center_my-performance",
|
"name": "personal-center_my-performance",
|
||||||
"path": "/personal-center/my-performance",
|
"path": "/personal-center/my-performance",
|
||||||
"component": "view.personal-center_my-performance",
|
"component": "view.personal-center_my-performance",
|
||||||
"title": "personal-center_my-performance",
|
"title": "我的绩效",
|
||||||
"routeTitle": "personal-center_my-performance",
|
"routeTitle": "personal-center_my-performance",
|
||||||
"i18nKey": "route.personal-center_my-performance",
|
"i18nKey": "route.personal-center_my-performance",
|
||||||
"icon": "mdi:trophy-outline",
|
"icon": "mdi:trophy-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 3,
|
"order": 4,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -356,11 +356,11 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "personal-center_my-performance",
|
"title": "我的绩效",
|
||||||
"i18nKey": "route.personal-center_my-performance",
|
"i18nKey": "route.personal-center_my-performance",
|
||||||
"icon": "mdi:trophy-outline",
|
"icon": "mdi:trophy-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 3,
|
"order": 4,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -375,12 +375,12 @@
|
|||||||
"name": "personal-center_my-application",
|
"name": "personal-center_my-application",
|
||||||
"path": "/personal-center/my-application",
|
"path": "/personal-center/my-application",
|
||||||
"component": "view.personal-center_my-application",
|
"component": "view.personal-center_my-application",
|
||||||
"title": "personal-center_my-application",
|
"title": "我的申请",
|
||||||
"routeTitle": "personal-center_my-application",
|
"routeTitle": "personal-center_my-application",
|
||||||
"i18nKey": "route.personal-center_my-application",
|
"i18nKey": "route.personal-center_my-application",
|
||||||
"icon": "mdi:file-document-outline",
|
"icon": "mdi:file-document-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 4,
|
"order": 5,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -389,11 +389,11 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "personal-center_my-application",
|
"title": "我的申请",
|
||||||
"i18nKey": "route.personal-center_my-application",
|
"i18nKey": "route.personal-center_my-application",
|
||||||
"icon": "mdi:file-document-outline",
|
"icon": "mdi:file-document-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 4,
|
"order": 5,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -408,12 +408,12 @@
|
|||||||
"name": "personal-center_pending-approval",
|
"name": "personal-center_pending-approval",
|
||||||
"path": "/personal-center/pending-approval",
|
"path": "/personal-center/pending-approval",
|
||||||
"component": "view.personal-center_pending-approval",
|
"component": "view.personal-center_pending-approval",
|
||||||
"title": "personal-center_pending-approval",
|
"title": "待我审批",
|
||||||
"routeTitle": "personal-center_pending-approval",
|
"routeTitle": "personal-center_pending-approval",
|
||||||
"i18nKey": "route.personal-center_pending-approval",
|
"i18nKey": "route.personal-center_pending-approval",
|
||||||
"icon": "mdi:check-decagram-outline",
|
"icon": "mdi:check-decagram-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 5,
|
"order": 7,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -422,11 +422,44 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "personal-center_pending-approval",
|
"title": "待我审批",
|
||||||
"i18nKey": "route.personal-center_pending-approval",
|
"i18nKey": "route.personal-center_pending-approval",
|
||||||
"icon": "mdi:check-decagram-outline",
|
"icon": "mdi:check-decagram-outline",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
"order": 5,
|
"order": 7,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_overtime-application",
|
||||||
|
"path": "/personal-center/overtime-application",
|
||||||
|
"component": "view.personal-center_overtime-application",
|
||||||
|
"title": "加班申请",
|
||||||
|
"routeTitle": "personal-center_overtime-application",
|
||||||
|
"i18nKey": "route.personal-center_overtime-application",
|
||||||
|
"icon": "mdi:clock-plus-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 6,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "加班申请",
|
||||||
|
"i18nKey": "route.personal-center_overtime-application",
|
||||||
|
"icon": "mdi:clock-plus-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 6,
|
||||||
"keepAlive": true,
|
"keepAlive": true,
|
||||||
"hideInMenu": false,
|
"hideInMenu": false,
|
||||||
"activeMenu": null,
|
"activeMenu": null,
|
||||||
@@ -639,7 +672,7 @@
|
|||||||
"name": "infra_state-machine",
|
"name": "infra_state-machine",
|
||||||
"path": "/infra/state-machine",
|
"path": "/infra/state-machine",
|
||||||
"component": "view.infra_state-machine",
|
"component": "view.infra_state-machine",
|
||||||
"title": "infra_state-machine",
|
"title": "状态机管理",
|
||||||
"routeTitle": "infra_state-machine",
|
"routeTitle": "infra_state-machine",
|
||||||
"i18nKey": "route.infra_state-machine",
|
"i18nKey": "route.infra_state-machine",
|
||||||
"icon": "mdi:state-machine",
|
"icon": "mdi:state-machine",
|
||||||
@@ -653,7 +686,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "infra_state-machine",
|
"title": "状态机管理",
|
||||||
"i18nKey": "route.infra_state-machine",
|
"i18nKey": "route.infra_state-machine",
|
||||||
"icon": "mdi:state-machine",
|
"icon": "mdi:state-machine",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
@@ -672,7 +705,7 @@
|
|||||||
"name": "infra_rd-code",
|
"name": "infra_rd-code",
|
||||||
"path": "/infra/rd-code",
|
"path": "/infra/rd-code",
|
||||||
"component": "view.infra_rd-code",
|
"component": "view.infra_rd-code",
|
||||||
"title": "infra_rd-code",
|
"title": "研发令号",
|
||||||
"routeTitle": "infra_rd-code",
|
"routeTitle": "infra_rd-code",
|
||||||
"i18nKey": "route.infra_rd-code",
|
"i18nKey": "route.infra_rd-code",
|
||||||
"icon": "mdi:identifier",
|
"icon": "mdi:identifier",
|
||||||
@@ -686,7 +719,7 @@
|
|||||||
"redirect": null,
|
"redirect": null,
|
||||||
"props": null,
|
"props": null,
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "infra_rd-code",
|
"title": "研发令号",
|
||||||
"i18nKey": "route.infra_rd-code",
|
"i18nKey": "route.infra_rd-code",
|
||||||
"icon": "mdi:identifier",
|
"icon": "mdi:identifier",
|
||||||
"localIcon": null,
|
"localIcon": null,
|
||||||
|
|||||||
@@ -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 数据源中
|
|
||||||
25
package.json
25
package.json
@@ -37,60 +37,39 @@
|
|||||||
"update-pkg": "sa update-pkg"
|
"update-pkg": "sa update-pkg"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antv/data-set": "0.11.8",
|
|
||||||
"@antv/g2": "5.4.0",
|
|
||||||
"@antv/g6": "5.0.49",
|
|
||||||
"@better-scroll/core": "2.5.1",
|
"@better-scroll/core": "2.5.1",
|
||||||
"@iconify-vue/mingcute": "^1.0.5",
|
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@sa/axios": "workspace:*",
|
"@sa/axios": "workspace:*",
|
||||||
"@sa/color": "workspace:*",
|
"@sa/color": "workspace:*",
|
||||||
"@sa/hooks": "workspace:*",
|
"@sa/hooks": "workspace:*",
|
||||||
"@sa/materials": "workspace:*",
|
"@sa/materials": "workspace:*",
|
||||||
"@sa/utils": "workspace:*",
|
"@sa/utils": "workspace:*",
|
||||||
"@visactor/vchart": "2.0.4",
|
|
||||||
"@visactor/vchart-theme": "1.12.2",
|
|
||||||
"@visactor/vtable-editors": "1.19.8",
|
|
||||||
"@visactor/vtable-gantt": "1.19.8",
|
|
||||||
"@visactor/vue-vtable": "1.19.8",
|
|
||||||
"@vueuse/components": "13.9.0",
|
|
||||||
"@vueuse/core": "13.9.0",
|
"@vueuse/core": "13.9.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"dayjs": "1.11.18",
|
"dayjs": "1.11.18",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
"dhtmlx-gantt": "9.0.14",
|
|
||||||
"dompurify": "3.2.6",
|
"dompurify": "3.2.6",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"element-plus": "^2.11.1",
|
"element-plus": "^2.11.1",
|
||||||
"jsbarcode": "3.12.1",
|
"grid-layout-plus": "^1.1.1",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"pinia": "3.0.3",
|
"pinia": "3.0.3",
|
||||||
"pinyin-pro": "3.27.0",
|
|
||||||
"print-js": "1.6.0",
|
|
||||||
"swiper": "11.2.10",
|
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"typeit": "8.8.7",
|
|
||||||
"vditor": "3.11.2",
|
|
||||||
"vue": "3.5.20",
|
"vue": "3.5.20",
|
||||||
"vue-draggable-plus": "0.6.0",
|
"vue-draggable-plus": "0.6.0",
|
||||||
"vue-i18n": "11.1.11",
|
"vue-i18n": "11.1.11",
|
||||||
"vue-pdf-embed": "2.1.3",
|
"vue-router": "4.5.1"
|
||||||
"vue-router": "4.5.1",
|
|
||||||
"xgplayer": "3.0.23",
|
|
||||||
"xlsx": "0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@amap/amap-jsapi-types": "0.0.15",
|
|
||||||
"@elegant-router/vue": "0.3.8",
|
"@elegant-router/vue": "0.3.8",
|
||||||
"@iconify/json": "2.2.380",
|
"@iconify/json": "2.2.380",
|
||||||
"@sa/scripts": "workspace:*",
|
"@sa/scripts": "workspace:*",
|
||||||
"@sa/uno-preset": "workspace:*",
|
"@sa/uno-preset": "workspace:*",
|
||||||
"@soybeanjs/eslint-config": "1.7.1",
|
"@soybeanjs/eslint-config": "1.7.1",
|
||||||
"@types/bmapgl": "0.0.7",
|
|
||||||
"@types/node": "24.3.0",
|
"@types/node": "24.3.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@unocss/eslint-config": "66.5.0",
|
"@unocss/eslint-config": "66.5.0",
|
||||||
|
|||||||
2458
pnpm-lock.yaml
generated
2458
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1018
src/components/custom/attendee-user-picker.vue
Normal file
1018
src/components/custom/attendee-user-picker.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -470,7 +470,7 @@ onBeforeUnmount(() => {
|
|||||||
</ElIcon>
|
</ElIcon>
|
||||||
<ElLink
|
<ElLink
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
underline="never"
|
||||||
class="business-attachment-uploader__name"
|
class="business-attachment-uploader__name"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
@click="handleOpen(item)"
|
@click="handleOpen(item)"
|
||||||
@@ -478,7 +478,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</ElLink>
|
</ElLink>
|
||||||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||||
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
|
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ onBeforeUnmount(() => {
|
|||||||
</ElIcon>
|
</ElIcon>
|
||||||
<ElLink
|
<ElLink
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
underline="never"
|
||||||
class="business-attachment-uploader__name"
|
class="business-attachment-uploader__name"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
@click="handleOpen(item)"
|
@click="handleOpen(item)"
|
||||||
@@ -517,7 +517,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</ElLink>
|
</ElLink>
|
||||||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||||
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
|
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
|
|||||||
<template>
|
<template>
|
||||||
<div class="business-rich-text-view">
|
<div class="business-rich-text-view">
|
||||||
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { computed, defineComponent, ref } from 'vue';
|
import { computed, defineComponent, h, ref } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { Component, PropType } from 'vue';
|
||||||
import { ElButton, ElPopover } from 'element-plus';
|
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
export type BusinessTableAction = {
|
export type BusinessTableAction = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
icon?: Component;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void | Promise<void>;
|
onClick: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -17,12 +18,20 @@ export default defineComponent({
|
|||||||
actions: {
|
actions: {
|
||||||
type: Array as PropType<BusinessTableAction[]>,
|
type: Array as PropType<BusinessTableAction[]>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String as PropType<'button' | 'icon'>,
|
||||||
|
default: 'button'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const popoverVisible = ref(false);
|
const popoverVisible = ref(false);
|
||||||
|
|
||||||
const directActions = computed(() => {
|
const directActions = computed(() => {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return props.actions;
|
||||||
|
}
|
||||||
|
|
||||||
if (props.actions.length <= 2) {
|
if (props.actions.length <= 2) {
|
||||||
return props.actions;
|
return props.actions;
|
||||||
}
|
}
|
||||||
@@ -31,6 +40,10 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moreActions = computed(() => {
|
const moreActions = computed(() => {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (props.actions.length <= 2) {
|
if (props.actions.length <= 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -47,9 +60,14 @@ export default defineComponent({
|
|||||||
await action.onClick();
|
await action.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => (
|
function renderIcon(action: BusinessTableAction) {
|
||||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
if (!action.icon) return null;
|
||||||
{directActions.value.map(action => (
|
|
||||||
|
return h(action.icon, { class: 'business-table-action-icon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderButtonAction(action: BusinessTableAction) {
|
||||||
|
return (
|
||||||
<ElButton
|
<ElButton
|
||||||
key={action.key}
|
key={action.key}
|
||||||
plain
|
plain
|
||||||
@@ -61,7 +79,67 @@ export default defineComponent({
|
|||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIconAction(action: BusinessTableAction) {
|
||||||
|
return (
|
||||||
|
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-icon-button"
|
||||||
|
aria-label={action.label}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{renderIcon(action)}
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenuButton(action: BusinessTableAction) {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return (
|
||||||
|
<ElButton
|
||||||
|
key={action.key}
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-menu__link"
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
<span class="business-table-action-menu__item">
|
||||||
|
{renderIcon(action)}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</span>
|
||||||
|
</ElButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElButton
|
||||||
|
key={action.key}
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-menu__button"
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ElButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||||
|
{directActions.value.map(action =>
|
||||||
|
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||||
|
)}
|
||||||
|
|
||||||
{moreActions.value.length > 0 && (
|
{moreActions.value.length > 0 && (
|
||||||
<ElPopover
|
<ElPopover
|
||||||
@@ -74,32 +152,28 @@ export default defineComponent({
|
|||||||
{{
|
{{
|
||||||
reference: () => (
|
reference: () => (
|
||||||
<ElButton
|
<ElButton
|
||||||
plain
|
link={props.variant === 'icon'}
|
||||||
|
plain={props.variant !== 'icon'}
|
||||||
size="small"
|
size="small"
|
||||||
class="business-table-action-button"
|
class={
|
||||||
|
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||||
|
}
|
||||||
|
aria-label={$t('common.more')}
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{props.variant === 'icon' ? (
|
||||||
|
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||||
|
) : (
|
||||||
<span class="inline-flex items-center gap-4px">
|
<span class="inline-flex items-center gap-4px">
|
||||||
{$t('common.more')}
|
{$t('common.more')}
|
||||||
<icon-mdi-chevron-down class="text-14px" />
|
<icon-mdi-chevron-down class="text-14px" />
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
),
|
),
|
||||||
default: () => (
|
default: () => (
|
||||||
<div class="business-table-action-menu">
|
<div class="business-table-action-menu">
|
||||||
{moreActions.value.map(action => (
|
{moreActions.value.map(action => renderMenuButton(action))}
|
||||||
<ElButton
|
|
||||||
key={action.key}
|
|
||||||
plain
|
|
||||||
size="small"
|
|
||||||
type={action.buttonType}
|
|
||||||
disabled={action.disabled}
|
|
||||||
class="business-table-action-menu__button"
|
|
||||||
onClick={() => handleAction(action)}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</ElButton>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
938
src/components/custom/business-user-picker.vue
Normal file
938
src/components/custom/business-user-picker.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
|
import { useDictStore } from '@/store/modules/dict';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
|
||||||
defineOptions({ name: 'DictSelect' });
|
defineOptions({ name: 'DictSelect' });
|
||||||
|
|
||||||
|
const ensuredEmptyDictCodes = new Set<string>();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dictCode: string;
|
dictCode: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -14,6 +17,8 @@ interface Props {
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
collapseTags?: boolean;
|
collapseTags?: boolean;
|
||||||
collapseTagsTooltip?: boolean;
|
collapseTagsTooltip?: boolean;
|
||||||
|
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||||
|
showRemark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
onlyEnabled: true,
|
onlyEnabled: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
collapseTags: false,
|
collapseTags: false,
|
||||||
collapseTagsTooltip: false
|
collapseTagsTooltip: false,
|
||||||
|
showRemark: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||||
default: undefined
|
default: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dictStore = useDictStore();
|
||||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||||
|
|
||||||
const dictOptions = computed(() => {
|
const dictOptions = computed(() => {
|
||||||
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
||||||
|
|
||||||
return source.map(item => ({
|
return source.map(item => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
value: item.value
|
value: item.value,
|
||||||
|
colorType: item.colorType ?? null,
|
||||||
|
remark: item.remark ?? null
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 单选时取当前选中项的 colorType,用于触发器 prefix 色块
|
||||||
|
const selectedColorType = computed<string | null>(() => {
|
||||||
|
if (props.multiple) return null;
|
||||||
|
const value = model.value;
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||||
|
async ([dictCode, optionCount, initialized, loading]) => {
|
||||||
|
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensuredEmptyDictCodes.add(dictCode);
|
||||||
|
await dictStore.ensureDictData(dictCode, true);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="model"
|
v-model="model"
|
||||||
class="w-full"
|
class="dict-select w-full"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
:clearable="props.clearable"
|
:clearable="props.clearable"
|
||||||
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
|
|||||||
:collapse-tags="props.collapseTags"
|
:collapse-tags="props.collapseTags"
|
||||||
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
||||||
>
|
>
|
||||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
|
<template v-if="selectedColorType" #prefix>
|
||||||
|
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
|
||||||
|
</template>
|
||||||
|
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||||
|
<span class="dict-select__option">
|
||||||
|
<span
|
||||||
|
v-if="item.colorType"
|
||||||
|
class="dict-select__color-dot dict-select__color-dot--option"
|
||||||
|
:style="{ background: item.colorType }"
|
||||||
|
/>
|
||||||
|
<span class="dict-select__option-label">{{ item.label }}</span>
|
||||||
|
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
|
||||||
|
</span>
|
||||||
|
</ElOption>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.dict-select__color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__color-dot--option {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option-remark {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
import DictText from './dict-text.vue';
|
import DictText from './dict-text.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'DictTag' });
|
defineOptions({ name: 'DictTag' });
|
||||||
@@ -14,6 +16,7 @@ interface Props {
|
|||||||
fallback?: string;
|
fallback?: string;
|
||||||
separator?: string;
|
separator?: string;
|
||||||
onlyEnabled?: boolean;
|
onlyEnabled?: boolean;
|
||||||
|
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
|
||||||
type?: DictTagType;
|
type?: DictTagType;
|
||||||
effect?: DictTagEffect;
|
effect?: DictTagEffect;
|
||||||
size?: DictTagSize;
|
size?: DictTagSize;
|
||||||
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
size: 'default',
|
size: 'default',
|
||||||
round: false
|
round: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { getItem } = useDict(() => props.dictCode);
|
||||||
|
|
||||||
|
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
|
||||||
|
const autoColorType = computed<string | null>(() => {
|
||||||
|
if (Array.isArray(props.value)) return null;
|
||||||
|
if (props.value === null || props.value === undefined || props.value === '') return null;
|
||||||
|
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// props.type 优先(向后兼容);其次字典 colorType(hex);都没有时回落到原生 ElTag 默认
|
||||||
|
const hexColor = computed(() => (props.type ? null : autoColorType.value));
|
||||||
|
|
||||||
|
const tagStyle = computed<Record<string, string> | null>(() => {
|
||||||
|
if (!hexColor.value) return null;
|
||||||
|
// light 效果:浅底 + 主色字 + 中浅边;plain/dark 同样的色调思路,仅明度差异
|
||||||
|
const fg = hexColor.value;
|
||||||
|
if (props.effect === 'dark') {
|
||||||
|
return {
|
||||||
|
color: '#fff',
|
||||||
|
background: fg,
|
||||||
|
borderColor: fg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (props.effect === 'plain') {
|
||||||
|
return {
|
||||||
|
color: fg,
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: `color-mix(in srgb, ${fg} 50%, white)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// light(默认)
|
||||||
|
return {
|
||||||
|
color: fg,
|
||||||
|
background: `color-mix(in srgb, ${fg} 12%, white)`,
|
||||||
|
borderColor: `color-mix(in srgb, ${fg} 30%, white)`
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
|
<ElTag
|
||||||
|
:type="props.type"
|
||||||
|
:effect="props.effect"
|
||||||
|
:size="props.size"
|
||||||
|
:round="props.round"
|
||||||
|
:style="tagStyle ?? undefined"
|
||||||
|
>
|
||||||
<DictText
|
<DictText
|
||||||
:dict-code="props.dictCode"
|
:dict-code="props.dictCode"
|
||||||
:value="props.value"
|
:value="props.value"
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ export interface SearchField {
|
|||||||
label: string;
|
label: string;
|
||||||
/** 字段类型 */
|
/** 字段类型 */
|
||||||
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
|
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
|
||||||
|
/** date 字段的日期粒度 */
|
||||||
|
dateType?: 'date' | 'month';
|
||||||
|
/** dateRange 字段的日期范围粒度 */
|
||||||
|
dateRangeType?: 'daterange' | 'monthrange';
|
||||||
|
/** 日期字段提交格式 */
|
||||||
|
valueFormat?: string;
|
||||||
/** 占位列数,默认 1 */
|
/** 占位列数,默认 1 */
|
||||||
span?: number;
|
span?: number;
|
||||||
/** select 类型的选项 */
|
/** select 类型的选项 */
|
||||||
options?: Option[];
|
options?: Option[];
|
||||||
/** dict 类型的字典编码 */
|
/** dict 类型的字典编码 */
|
||||||
dictCode?: string;
|
dictCode?: string;
|
||||||
|
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急") */
|
||||||
|
showRemark?: boolean;
|
||||||
/** 占位提示文本 */
|
/** 占位提示文本 */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** select 类型的自定义选项渲染函数 */
|
/** select 类型的自定义选项渲染函数 */
|
||||||
@@ -154,23 +162,23 @@ function handleSearch() {
|
|||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:model-value="props.modelValue[field.key]"
|
:model-value="props.modelValue[field.key]"
|
||||||
type="date"
|
:type="field.dateType || 'date'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
clearable
|
clearable
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
value-format="YYYY-MM-DD"
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-else-if="field.type === 'dateRange'"
|
v-else-if="field.type === 'dateRange'"
|
||||||
:model-value="props.modelValue[field.key]"
|
:model-value="props.modelValue[field.key]"
|
||||||
type="daterange"
|
:type="field.dateRangeType || 'daterange'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
clearable
|
clearable
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
value-format="YYYY-MM-DD"
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||||
end-placeholder="结束日期"
|
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
<DictSelect
|
<DictSelect
|
||||||
@@ -179,6 +187,7 @@ function handleSearch() {
|
|||||||
:dict-code="field.dictCode!"
|
:dict-code="field.dictCode!"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
|
:show-remark="field.showRemark"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
@@ -250,23 +259,23 @@ function handleSearch() {
|
|||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:model-value="props.modelValue[field.key]"
|
:model-value="props.modelValue[field.key]"
|
||||||
type="date"
|
:type="field.dateType || 'date'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
clearable
|
clearable
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
value-format="YYYY-MM-DD"
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-else-if="field.type === 'dateRange'"
|
v-else-if="field.type === 'dateRange'"
|
||||||
:model-value="props.modelValue[field.key]"
|
:model-value="props.modelValue[field.key]"
|
||||||
type="daterange"
|
:type="field.dateRangeType || 'daterange'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
clearable
|
clearable
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
value-format="YYYY-MM-DD"
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
start-placeholder="开始日期"
|
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||||
end-placeholder="结束日期"
|
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
<DictSelect
|
<DictSelect
|
||||||
@@ -275,6 +284,7 @@ function handleSearch() {
|
|||||||
:dict-code="field.dictCode!"
|
:dict-code="field.dictCode!"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
|
:show-remark="field.showRemark"
|
||||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { getPaletteColorByNumber } from '@sa/color';
|
|
||||||
|
|
||||||
defineOptions({ name: 'WaveBg' });
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Theme color */
|
|
||||||
themeColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
|
|
||||||
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="absolute-lt z-1 size-full overflow-hidden">
|
|
||||||
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
|
||||||
<svg height="1337" width="1337">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-1"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
|
||||||
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
|
||||||
<svg height="896" width="967.8852157128662">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-2"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
|
||||||
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -45,10 +45,14 @@ export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
|
|||||||
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需求优先级字典编码
|
* 优先级字典编码
|
||||||
*
|
*
|
||||||
* 对应业务字段:需求相关接口和页面中的 priority
|
* 对应业务字段:
|
||||||
* 来源口径:产品需求文档中定义,标签包括P0、P1、P2、P3
|
* - 需求(产品需求 / 项目需求)的 priority(旧口径:Integer,数字大=高,0=低 / 3=紧急)
|
||||||
|
* - 任务 / 执行的 priority(新口径:String "0"~"3",数字越小优先级越高,"1"=默认 P1)
|
||||||
|
*
|
||||||
|
* 来源口径:后端统一字典 rdms_req_priority,4 档标签 P0/P1/P2/P3。
|
||||||
|
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
|
||||||
*/
|
*/
|
||||||
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
||||||
|
|
||||||
@@ -84,6 +88,14 @@ export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_typ
|
|||||||
*/
|
*/
|
||||||
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = '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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需求允许删除的状态字典编码
|
* 需求允许删除的状态字典编码
|
||||||
*
|
*
|
||||||
@@ -91,3 +103,19 @@ export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_ob
|
|||||||
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
||||||
*/
|
*/
|
||||||
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作日志难度字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||||
|
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
|
||||||
|
*/
|
||||||
|
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加班时长快捷选项字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:加班申请中的 overtimeDuration
|
||||||
|
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
|
||||||
|
*/
|
||||||
|
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/** baidu map sdk url */
|
|
||||||
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
|
|
||||||
|
|
||||||
/** Amap sdk url */
|
|
||||||
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
|
|
||||||
|
|
||||||
/** tencent sdk url */
|
|
||||||
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';
|
|
||||||
@@ -14,8 +14,12 @@ export type StatusDomain =
|
|||||||
| 'taskAssigneeMember'
|
| 'taskAssigneeMember'
|
||||||
| 'project'
|
| 'project'
|
||||||
| 'product'
|
| 'product'
|
||||||
| 'requirement'
|
| 'productRequirement'
|
||||||
| 'workOrder';
|
| 'projectRequirement'
|
||||||
|
| 'workOrder'
|
||||||
|
| 'workReport'
|
||||||
|
| 'personalItem'
|
||||||
|
| 'overtimeApplication';
|
||||||
|
|
||||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||||
// 项目-执行
|
// 项目-执行
|
||||||
@@ -50,10 +54,53 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
|||||||
project: {},
|
project: {},
|
||||||
// 产品(待补全)
|
// 产品(待补全)
|
||||||
product: {},
|
product: {},
|
||||||
// 需求(待补全)
|
// 产品需求
|
||||||
requirement: {},
|
productRequirement: {
|
||||||
|
pending_claim: 'info',
|
||||||
|
pending_review: 'info',
|
||||||
|
pending_dispatch: 'primary',
|
||||||
|
reviewed: 'success',
|
||||||
|
review_rejected: 'danger',
|
||||||
|
implementing: 'primary',
|
||||||
|
accepted: 'success',
|
||||||
|
closed: 'danger',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 项目需求
|
||||||
|
projectRequirement: {
|
||||||
|
pending_claim: 'info',
|
||||||
|
pending_review: 'info',
|
||||||
|
reviewed: 'success',
|
||||||
|
review_rejected: 'danger',
|
||||||
|
implementing: 'primary',
|
||||||
|
accepted: 'success',
|
||||||
|
closed: 'danger',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
// 工单(待补全)
|
// 工单(待补全)
|
||||||
workOrder: {}
|
workOrder: {},
|
||||||
|
// 工作报告
|
||||||
|
workReport: {
|
||||||
|
draft: 'info',
|
||||||
|
pending_approval: 'warning',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger'
|
||||||
|
},
|
||||||
|
// 个人事项
|
||||||
|
personalItem: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 加班申请
|
||||||
|
overtimeApplication: {
|
||||||
|
pending: 'warning',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
||||||
@@ -61,5 +108,9 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
|
|||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
return statusTagTypeRegistry[domain][statusCode] || 'info';
|
return statusTagTypeRegistry[domain]?.[statusCode] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
|
||||||
|
return getStatusTagType('personalItem', statusCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export enum SetupStoreId {
|
|||||||
Dict = 'dict-store',
|
Dict = 'dict-store',
|
||||||
Route = 'route-store',
|
Route = 'route-store',
|
||||||
Tab = 'tab-store',
|
Tab = 'tab-store',
|
||||||
ObjectContext = 'object-context-store'
|
ObjectContext = 'object-context-store',
|
||||||
|
Workbench = 'workbench-store'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
|||||||
* @param callback callback function
|
* @param callback callback function
|
||||||
*/
|
*/
|
||||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||||
if (!isRendered()) return;
|
|
||||||
|
|
||||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||||
|
|
||||||
Object.assign(chartOptions, updatedOpts);
|
Object.assign(chartOptions, updatedOpts);
|
||||||
|
|
||||||
|
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options,待 render() 初始化时一并应用;
|
||||||
|
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
|
||||||
|
if (!isRendered()) return;
|
||||||
|
|
||||||
if (isRendered()) {
|
if (isRendered()) {
|
||||||
chart?.clear();
|
chart?.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { computed, effectScope, onScopeDispose, ref, watch } from 'vue';
|
|
||||||
import { useElementSize } from '@vueuse/core';
|
|
||||||
import VChart, { registerLiquidChart } from '@visactor/vchart';
|
|
||||||
import type { ISpec, ITheme } from '@visactor/vchart';
|
|
||||||
import light from '@visactor/vchart-theme/public/light.json';
|
|
||||||
import dark from '@visactor/vchart-theme/public/dark.json';
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
|
||||||
|
|
||||||
registerLiquidChart();
|
|
||||||
|
|
||||||
// register the theme
|
|
||||||
VChart.ThemeManager.registerTheme('light', light as ITheme);
|
|
||||||
VChart.ThemeManager.registerTheme('dark', dark as ITheme);
|
|
||||||
|
|
||||||
interface ChartHooks {
|
|
||||||
onRender?: (chart: VChart) => void | Promise<void>;
|
|
||||||
onUpdated?: (chart: VChart) => void | Promise<void>;
|
|
||||||
onDestroy?: (chart: VChart) => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVChart<T extends ISpec>(specFactory: () => T, hooks: ChartHooks = {}) {
|
|
||||||
const scope = effectScope();
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const darkMode = computed(() => themeStore.darkMode);
|
|
||||||
|
|
||||||
const domRef = ref<HTMLElement | null>(null);
|
|
||||||
const initialSize = { width: 0, height: 0 };
|
|
||||||
const { width, height } = useElementSize(domRef, initialSize);
|
|
||||||
|
|
||||||
let chart: VChart | null = null;
|
|
||||||
const spec: T = specFactory();
|
|
||||||
|
|
||||||
const { onRender, onUpdated, onDestroy } = hooks;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* whether can render chart
|
|
||||||
*
|
|
||||||
* when domRef is ready and initialSize is valid
|
|
||||||
*/
|
|
||||||
function canRender() {
|
|
||||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** is chart rendered */
|
|
||||||
function isRendered() {
|
|
||||||
return Boolean(domRef.value && chart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update chart spec
|
|
||||||
*
|
|
||||||
* @param callback callback function
|
|
||||||
*/
|
|
||||||
async function updateSpec(callback: (opts: T, optsFactory: () => T) => ISpec = () => spec) {
|
|
||||||
if (!isRendered()) return;
|
|
||||||
|
|
||||||
const updatedOpts = callback(spec, specFactory);
|
|
||||||
|
|
||||||
Object.assign(spec, updatedOpts);
|
|
||||||
|
|
||||||
// if (isRendered()) {
|
|
||||||
// chart?.release();
|
|
||||||
// }
|
|
||||||
|
|
||||||
chart?.updateSpec({ ...updatedOpts }, true);
|
|
||||||
|
|
||||||
await onUpdated?.(chart!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSpec(newSpec: T) {
|
|
||||||
chart?.updateSpec(newSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** render chart */
|
|
||||||
async function render() {
|
|
||||||
if (!isRendered()) {
|
|
||||||
// apply the theme
|
|
||||||
if (darkMode.value) {
|
|
||||||
VChart.ThemeManager.setCurrentTheme('dark');
|
|
||||||
} else {
|
|
||||||
VChart.ThemeManager.setCurrentTheme('light');
|
|
||||||
}
|
|
||||||
|
|
||||||
chart = new VChart(spec, { dom: domRef.value as HTMLElement });
|
|
||||||
chart.renderSync();
|
|
||||||
|
|
||||||
await onRender?.(chart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** resize chart */
|
|
||||||
function resize() {
|
|
||||||
// chart?.resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** destroy chart */
|
|
||||||
async function destroy() {
|
|
||||||
if (!chart) return;
|
|
||||||
|
|
||||||
await onDestroy?.(chart);
|
|
||||||
chart?.release();
|
|
||||||
chart = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** change chart theme */
|
|
||||||
async function changeTheme() {
|
|
||||||
await destroy();
|
|
||||||
await render();
|
|
||||||
await onUpdated?.(chart!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* render chart by size
|
|
||||||
*
|
|
||||||
* @param w width
|
|
||||||
* @param h height
|
|
||||||
*/
|
|
||||||
async function renderChartBySize(w: number, h: number) {
|
|
||||||
initialSize.width = w;
|
|
||||||
initialSize.height = h;
|
|
||||||
|
|
||||||
// size is abnormal, destroy chart
|
|
||||||
if (!canRender()) {
|
|
||||||
await destroy();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// resize chart
|
|
||||||
if (isRendered()) {
|
|
||||||
resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// render chart
|
|
||||||
await render();
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.run(() => {
|
|
||||||
watch([width, height], ([newWidth, newHeight]) => {
|
|
||||||
renderChartBySize(newWidth, newHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(darkMode, () => {
|
|
||||||
changeTheme();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
destroy();
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
domRef,
|
|
||||||
updateSpec,
|
|
||||||
setSpec
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
fetchGetMyNotifyMessagePage,
|
||||||
|
fetchGetUnreadNotifyCount,
|
||||||
|
fetchUpdateAllNotifyMessageRead,
|
||||||
|
fetchUpdateNotifyMessageRead
|
||||||
|
} from '@/service/api';
|
||||||
|
import { formatRelativeTime } from '@/utils/datetime';
|
||||||
|
|
||||||
|
defineOptions({ name: 'NotificationBell' });
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const UNREAD_COUNT_POLL_INTERVAL = 30 * 1000;
|
||||||
|
|
||||||
|
type TabKey = 'unread' | 'read';
|
||||||
|
|
||||||
|
interface MessageListState {
|
||||||
|
items: Api.NotifyMessage.NotifyMessage[];
|
||||||
|
pageNo: number;
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
/** 是否已按当前关键字拉过第一页(tab 懒加载 / 失效重拉用) */
|
||||||
|
loaded: boolean;
|
||||||
|
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
|
||||||
|
token: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListState(): MessageListState {
|
||||||
|
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const listStates = reactive<Record<TabKey, MessageListState>>({
|
||||||
|
unread: createListState(),
|
||||||
|
read: createListState()
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||||
|
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const activeTab = ref<TabKey>('unread');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
function keywordParam() {
|
||||||
|
return searchKeyword.value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUnreadCount() {
|
||||||
|
const { data, error } = await fetchGetUnreadNotifyCount();
|
||||||
|
if (!error && typeof data === 'number') {
|
||||||
|
unreadCount.value = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetList(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
state.token += 1;
|
||||||
|
state.items = [];
|
||||||
|
state.pageNo = 1;
|
||||||
|
state.total = 0;
|
||||||
|
state.loading = false;
|
||||||
|
state.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
if (state.loading) return;
|
||||||
|
|
||||||
|
const token = state.token;
|
||||||
|
state.loading = true;
|
||||||
|
|
||||||
|
const { data, error } = await fetchGetMyNotifyMessagePage({
|
||||||
|
pageNo: state.pageNo,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
readStatus: tab === 'read',
|
||||||
|
keyword: keywordParam()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (token !== state.token) return;
|
||||||
|
|
||||||
|
state.loading = false;
|
||||||
|
state.loaded = true;
|
||||||
|
|
||||||
|
if (error || !data) return;
|
||||||
|
|
||||||
|
state.items.push(...data.list);
|
||||||
|
state.total = data.total;
|
||||||
|
state.pageNo += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMore(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
return state.loaded && state.items.length < state.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLoaded(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
if (!state.loaded && !state.loading) {
|
||||||
|
loadPage(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyKeywordSearch = useDebounceFn(() => {
|
||||||
|
if (!drawerOpen.value) return;
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
applyKeywordSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeTab, tab => {
|
||||||
|
ensureLoaded(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||||
|
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => unreadScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
|
||||||
|
loadPage('unread');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => readScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
|
||||||
|
loadPage('read');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
function openDrawer() {
|
||||||
|
drawerOpen.value = true;
|
||||||
|
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
refreshUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrawerClosed() {
|
||||||
|
searchKeyword.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
|
||||||
|
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
|
||||||
|
const state = listStates.unread;
|
||||||
|
const index = state.items.findIndex(row => row.id === item.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
state.items.splice(index, 1);
|
||||||
|
state.total = Math.max(0, state.total - 1);
|
||||||
|
}
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||||
|
|
||||||
|
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
|
||||||
|
resetList('read');
|
||||||
|
|
||||||
|
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
|
||||||
|
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
|
||||||
|
loadPage('unread');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
const { error } = await fetchUpdateAllNotifyMessageRead();
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
unreadCount.value = 0;
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshUnreadCount();
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
refreshUnreadCount();
|
||||||
|
}, UNREAD_COUNT_POLL_INTERVAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="notification-bell__trigger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
|
||||||
|
@click="openDrawer"
|
||||||
|
>
|
||||||
|
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
|
||||||
|
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
|
||||||
|
<template #header>
|
||||||
|
<div class="notification-bell__header-main">
|
||||||
|
<span class="notification-bell__title">
|
||||||
|
通知
|
||||||
|
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||||
|
</span>
|
||||||
|
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="notification-bell__panel">
|
||||||
|
<div class="notification-bell__search">
|
||||||
|
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:magnify" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTabs v-model="activeTab" class="notification-bell__tabs">
|
||||||
|
<ElTabPane name="unread">
|
||||||
|
<template #label>
|
||||||
|
<span class="notification-bell__tab-label">
|
||||||
|
未读
|
||||||
|
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
|
||||||
|
<li
|
||||||
|
v-for="row in listStates.unread.items"
|
||||||
|
:key="row.id"
|
||||||
|
class="notification-bell__row is-unread"
|
||||||
|
@click="markRead(row)"
|
||||||
|
>
|
||||||
|
<span class="notification-bell__row-dot" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
||||||
|
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
|
||||||
|
<ElTabPane name="read">
|
||||||
|
<template #label>
|
||||||
|
<span class="notification-bell__tab-label">
|
||||||
|
已读
|
||||||
|
<span class="notification-bell__tab-count">{{ listStates.read.total }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
|
||||||
|
<li v-for="row in listStates.read.items" :key="row.id" class="notification-bell__row">
|
||||||
|
<span class="notification-bell__row-dot" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
||||||
|
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="closeDrawer">关闭</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-bell__trigger {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__trigger:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
animation: notification-badge-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
|
||||||
|
.notification-bell__badge::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
animation: notification-badge-ping 1.6s ease-out infinite;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notification-badge-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notification-badge-ping {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__header-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__title-count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__search {
|
||||||
|
padding: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tab-pane) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tab-count {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__scroll {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px minmax(0, 1fr);
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row + .notification-bell__row {
|
||||||
|
border-top: 1px dashed var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: transparent;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread .notification-bell__row-dot {
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-title {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread .notification-bell__row-title {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-time {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__footer-hint {
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
|||||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||||
import GlobalSearch from '../global-search/index.vue';
|
import GlobalSearch from '../global-search/index.vue';
|
||||||
import ThemeButton from './components/theme-button.vue';
|
import ThemeButton from './components/theme-button.vue';
|
||||||
|
import NotificationBell from './components/notification-bell.vue';
|
||||||
import UserAvatar from './components/user-avatar.vue';
|
import UserAvatar from './components/user-avatar.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'GlobalHeader' });
|
defineOptions({ name: 'GlobalHeader' });
|
||||||
@@ -40,14 +41,10 @@ const { isFullscreen, toggle } = useFullscreen();
|
|||||||
<div>
|
<div>
|
||||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||||
</div>
|
</div>
|
||||||
<ThemeSchemaSwitch
|
|
||||||
:theme-schema="themeStore.themeScheme"
|
|
||||||
:is-dark="themeStore.darkMode"
|
|
||||||
@switch="themeStore.toggleThemeScheme"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
<UserAvatar />
|
<UserAvatar />
|
||||||
</div>
|
</div>
|
||||||
</DarkModeContainer>
|
</DarkModeContainer>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
|
|||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||||
|
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
|
||||||
import { useMenu, useMixMenuContext } from '../../../context';
|
import { useMenu, useMixMenuContext } from '../../../context';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -108,7 +109,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||||
></div>
|
></div>
|
||||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showObjectContextInfo && headerMenus.length"
|
v-if="showObjectContextInfo && headerMenus.length"
|
||||||
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-object-tag {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-object-tag__label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 14rem;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid rgb(148 163 184 / 26%);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
|
||||||
color: rgb(15 23 42 / 88%);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav-list {
|
.header-nav-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { themeSchemaRecord } from '@/constants/app';
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
import SettingItem from '../components/setting-item.vue';
|
import SettingItem from '../components/setting-item.vue';
|
||||||
@@ -9,16 +8,6 @@ defineOptions({ name: 'DarkMode' });
|
|||||||
|
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
|
||||||
light: 'material-symbols:sunny',
|
|
||||||
dark: 'material-symbols:nightlight-rounded',
|
|
||||||
auto: 'material-symbols:hdr-auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleSegmentChange(value: string | number) {
|
|
||||||
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGrayscaleChange(value: boolean) {
|
function handleGrayscaleChange(value: boolean) {
|
||||||
themeStore.setGrayscale(value);
|
themeStore.setGrayscale(value);
|
||||||
}
|
}
|
||||||
@@ -33,15 +22,6 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
|||||||
<template>
|
<template>
|
||||||
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
||||||
<div class="flex-col-stretch gap-16px">
|
<div class="flex-col-stretch gap-16px">
|
||||||
<div class="i-flex-center">
|
|
||||||
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
|
|
||||||
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
|
|
||||||
<template #label>
|
|
||||||
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
|
|
||||||
</template>
|
|
||||||
</ElTabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</div>
|
|
||||||
<Transition name="sider-inverted">
|
<Transition name="sider-inverted">
|
||||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
||||||
<ElSwitch v-model="themeStore.sider.inverted" />
|
<ElSwitch v-model="themeStore.sider.inverted" />
|
||||||
|
|||||||
@@ -168,24 +168,18 @@ const local: App.I18n.Schema = {
|
|||||||
metrics_worktime: 'Worktime',
|
metrics_worktime: 'Worktime',
|
||||||
'personal-center': 'Personal Center',
|
'personal-center': 'Personal Center',
|
||||||
'personal-center_my-profile': 'My Profile',
|
'personal-center_my-profile': 'My Profile',
|
||||||
'personal-center_my-weekly': 'My Weekly Report',
|
'personal-center_my-item': 'My Items',
|
||||||
'personal-center_my-monthly': 'My Monthly Report',
|
'personal-center_work-report': 'Work Report',
|
||||||
|
'personal-center_work-report_weekly': 'Weekly Report',
|
||||||
|
'personal-center_work-report_monthly': 'Monthly Report',
|
||||||
|
'personal-center_work-report_project': 'Project Fortnightly Report',
|
||||||
'personal-center_my-performance': 'My Performance',
|
'personal-center_my-performance': 'My Performance',
|
||||||
'personal-center_my-application': 'My Application',
|
'personal-center_my-application': 'My Application',
|
||||||
|
'personal-center_overtime-application': 'Overtime Application',
|
||||||
'personal-center_pending-approval': 'Pending Approval',
|
'personal-center_pending-approval': 'Pending Approval',
|
||||||
infra: 'Infra',
|
infra: 'Infra',
|
||||||
'infra_state-machine': 'State Machine',
|
'infra_state-machine': 'State Machine',
|
||||||
'infra_rd-code': 'R&D Code',
|
'infra_rd-code': 'R&D Code',
|
||||||
function: 'System Function',
|
|
||||||
function_tab: 'Tab',
|
|
||||||
'function_multi-tab': 'Multi Tab',
|
|
||||||
'function_hide-child': 'Hide Child',
|
|
||||||
'function_hide-child_one': 'Hide Child',
|
|
||||||
'function_hide-child_two': 'Two',
|
|
||||||
'function_hide-child_three': 'Three',
|
|
||||||
function_request: 'Request',
|
|
||||||
'function_toggle-auth': 'Toggle Auth',
|
|
||||||
'function_super-page': 'Super Admin Visible',
|
|
||||||
product: 'Product',
|
product: 'Product',
|
||||||
product_list: 'Product List',
|
product_list: 'Product List',
|
||||||
product_dashboard: 'Dashboard',
|
product_dashboard: 'Dashboard',
|
||||||
@@ -209,28 +203,7 @@ const local: App.I18n.Schema = {
|
|||||||
exception: 'Exception',
|
exception: 'Exception',
|
||||||
exception_403: '403',
|
exception_403: '403',
|
||||||
exception_404: '404',
|
exception_404: '404',
|
||||||
exception_500: '500',
|
exception_500: '500'
|
||||||
plugin: 'Plugin',
|
|
||||||
plugin_copy: 'Copy',
|
|
||||||
plugin_charts: 'Charts',
|
|
||||||
plugin_charts_echarts: 'ECharts',
|
|
||||||
plugin_charts_antv: 'AntV',
|
|
||||||
plugin_charts_vchart: 'VChart',
|
|
||||||
plugin_icon: 'Icon',
|
|
||||||
plugin_map: 'Map',
|
|
||||||
plugin_print: 'Print',
|
|
||||||
plugin_swiper: 'Swiper',
|
|
||||||
plugin_video: 'Video',
|
|
||||||
plugin_barcode: 'Barcode',
|
|
||||||
plugin_pinyin: 'pinyin',
|
|
||||||
plugin_excel: 'Excel',
|
|
||||||
plugin_pdf: 'PDF preview',
|
|
||||||
plugin_gantt: 'Gantt Chart',
|
|
||||||
plugin_gantt_dhtmlx: 'dhtmlxGantt',
|
|
||||||
plugin_gantt_vtable: 'VTableGantt',
|
|
||||||
plugin_typeit: 'Typeit',
|
|
||||||
plugin_tables: 'Tables',
|
|
||||||
plugin_tables_vtable: 'VTable'
|
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
@@ -326,45 +299,6 @@ const local: App.I18n.Schema = {
|
|||||||
},
|
},
|
||||||
creativity: 'Creativity'
|
creativity: 'Creativity'
|
||||||
},
|
},
|
||||||
function: {
|
|
||||||
tab: {
|
|
||||||
tabOperate: {
|
|
||||||
title: 'Tab Operation',
|
|
||||||
addTab: 'Add Tab',
|
|
||||||
addTabDesc: 'To user management page',
|
|
||||||
closeTab: 'Close Tab',
|
|
||||||
closeCurrentTab: 'Close Current Tab',
|
|
||||||
closeAboutTab: 'Close "User Management" Tab',
|
|
||||||
addMultiTab: 'Add Multi Tab',
|
|
||||||
addMultiTabDesc1: 'To MultiTab page',
|
|
||||||
addMultiTabDesc2: 'To MultiTab page(with query params)'
|
|
||||||
},
|
|
||||||
tabTitle: {
|
|
||||||
title: 'Tab Title',
|
|
||||||
changeTitle: 'Change Title',
|
|
||||||
change: 'Change',
|
|
||||||
resetTitle: 'Reset Title',
|
|
||||||
reset: 'Reset'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
multiTab: {
|
|
||||||
routeParam: 'Route Param',
|
|
||||||
backTab: 'Back function_tab'
|
|
||||||
},
|
|
||||||
toggleAuth: {
|
|
||||||
toggleAccount: 'Toggle Account',
|
|
||||||
authHook: 'Auth Hook Function `hasAuth`',
|
|
||||||
superAdminVisible: 'Super Admin Visible',
|
|
||||||
adminVisible: 'Admin Visible',
|
|
||||||
adminOrUserVisible: 'Admin and User Visible'
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
|
|
||||||
repeatedError: 'Repeated Request Error',
|
|
||||||
repeatedErrorMsg1: 'Custom Request Error 1',
|
|
||||||
repeatedErrorMsg2: 'Custom Request Error 2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
system: {
|
system: {
|
||||||
common: {
|
common: {
|
||||||
status: {
|
status: {
|
||||||
@@ -707,6 +641,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: 'Dictionary Status',
|
dictStatus: 'Dictionary Status',
|
||||||
dictLabel: 'Dictionary Label',
|
dictLabel: 'Dictionary Label',
|
||||||
dictValue: 'Dictionary Value',
|
dictValue: 'Dictionary Value',
|
||||||
|
colorType: 'Color Type',
|
||||||
sort: 'Sort',
|
sort: 'Sort',
|
||||||
remark: 'Remark',
|
remark: 'Remark',
|
||||||
form: {
|
form: {
|
||||||
@@ -715,6 +650,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: 'Please select dictionary status',
|
dictStatus: 'Please select dictionary status',
|
||||||
dictLabel: 'Please enter dictionary label',
|
dictLabel: 'Please enter dictionary label',
|
||||||
dictValue: 'Please enter dictionary value',
|
dictValue: 'Please enter dictionary value',
|
||||||
|
colorType: 'Please enter color type',
|
||||||
sort: 'Please enter sort',
|
sort: 'Please enter sort',
|
||||||
remark: 'Please enter remark'
|
remark: 'Please enter remark'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const local: App.I18n.Schema = {
|
const local: App.I18n.Schema = {
|
||||||
system: {
|
system: {
|
||||||
title: '研发内部管理系统'
|
title: '研发管理系统'
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
action: '操作',
|
action: '操作',
|
||||||
@@ -168,24 +168,18 @@ const local: App.I18n.Schema = {
|
|||||||
metrics_worktime: '工时统计',
|
metrics_worktime: '工时统计',
|
||||||
'personal-center': '个人中心',
|
'personal-center': '个人中心',
|
||||||
'personal-center_my-profile': '个人信息',
|
'personal-center_my-profile': '个人信息',
|
||||||
'personal-center_my-weekly': '我的周报',
|
'personal-center_my-item': '我的事项',
|
||||||
'personal-center_my-monthly': '我的月报',
|
'personal-center_work-report': '工作报告',
|
||||||
|
'personal-center_work-report_weekly': '个人周报',
|
||||||
|
'personal-center_work-report_monthly': '个人月报',
|
||||||
|
'personal-center_work-report_project': '项目半月报',
|
||||||
'personal-center_my-performance': '我的绩效',
|
'personal-center_my-performance': '我的绩效',
|
||||||
'personal-center_my-application': '我的申请',
|
'personal-center_my-application': '我的申请',
|
||||||
|
'personal-center_overtime-application': '加班申请',
|
||||||
'personal-center_pending-approval': '待我审批',
|
'personal-center_pending-approval': '待我审批',
|
||||||
infra: '基础设施',
|
infra: '基础设施',
|
||||||
'infra_state-machine': '状态机管理',
|
'infra_state-machine': '状态机管理',
|
||||||
'infra_rd-code': '研发令号',
|
'infra_rd-code': '研发令号',
|
||||||
function: '系统功能',
|
|
||||||
function_tab: '标签页',
|
|
||||||
'function_multi-tab': '多标签页',
|
|
||||||
'function_hide-child': '隐藏子菜单',
|
|
||||||
'function_hide-child_one': '隐藏子菜单',
|
|
||||||
'function_hide-child_two': '菜单二',
|
|
||||||
'function_hide-child_three': '菜单三',
|
|
||||||
function_request: '请求',
|
|
||||||
'function_toggle-auth': '切换权限',
|
|
||||||
'function_super-page': '超级管理员可见',
|
|
||||||
product: '产品管理',
|
product: '产品管理',
|
||||||
product_list: '产品列表',
|
product_list: '产品列表',
|
||||||
product_dashboard: '产品仪表盘',
|
product_dashboard: '产品仪表盘',
|
||||||
@@ -209,28 +203,7 @@ const local: App.I18n.Schema = {
|
|||||||
exception: '异常页',
|
exception: '异常页',
|
||||||
exception_403: '403',
|
exception_403: '403',
|
||||||
exception_404: '404',
|
exception_404: '404',
|
||||||
exception_500: '500',
|
exception_500: '500'
|
||||||
plugin: '插件示例',
|
|
||||||
plugin_copy: '剪贴板',
|
|
||||||
plugin_charts: '图表',
|
|
||||||
plugin_charts_echarts: 'ECharts',
|
|
||||||
plugin_charts_antv: 'AntV',
|
|
||||||
plugin_charts_vchart: 'VChart',
|
|
||||||
plugin_icon: '图标',
|
|
||||||
plugin_map: '地图',
|
|
||||||
plugin_print: '打印',
|
|
||||||
plugin_swiper: 'Swiper',
|
|
||||||
plugin_video: '视频',
|
|
||||||
plugin_barcode: '条形码',
|
|
||||||
plugin_pinyin: '拼音',
|
|
||||||
plugin_excel: 'Excel',
|
|
||||||
plugin_pdf: 'PDF 预览',
|
|
||||||
plugin_gantt: '甘特图',
|
|
||||||
plugin_gantt_dhtmlx: 'dhtmlxGantt',
|
|
||||||
plugin_gantt_vtable: 'VTableGantt',
|
|
||||||
plugin_typeit: '打字机',
|
|
||||||
plugin_tables: '表格',
|
|
||||||
plugin_tables_vtable: 'VTable'
|
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
@@ -282,7 +255,7 @@ const local: App.I18n.Schema = {
|
|||||||
about: {
|
about: {
|
||||||
title: '关于',
|
title: '关于',
|
||||||
introduction:
|
introduction:
|
||||||
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
'灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
||||||
projectInfo: {
|
projectInfo: {
|
||||||
title: '项目信息',
|
title: '项目信息',
|
||||||
version: '版本',
|
version: '版本',
|
||||||
@@ -325,45 +298,6 @@ const local: App.I18n.Schema = {
|
|||||||
},
|
},
|
||||||
creativity: '创意'
|
creativity: '创意'
|
||||||
},
|
},
|
||||||
function: {
|
|
||||||
tab: {
|
|
||||||
tabOperate: {
|
|
||||||
title: '标签页操作',
|
|
||||||
addTab: '添加标签页',
|
|
||||||
addTabDesc: '跳转到用户管理页面',
|
|
||||||
closeTab: '关闭标签页',
|
|
||||||
closeCurrentTab: '关闭当前标签页',
|
|
||||||
closeAboutTab: '关闭"用户管理"标签页',
|
|
||||||
addMultiTab: '添加多标签页',
|
|
||||||
addMultiTabDesc1: '跳转到多标签页页面',
|
|
||||||
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
|
|
||||||
},
|
|
||||||
tabTitle: {
|
|
||||||
title: '标签页标题',
|
|
||||||
changeTitle: '修改标题',
|
|
||||||
change: '修改',
|
|
||||||
resetTitle: '重置标题',
|
|
||||||
reset: '重置'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
multiTab: {
|
|
||||||
routeParam: '路由参数',
|
|
||||||
backTab: '返回 function_tab'
|
|
||||||
},
|
|
||||||
toggleAuth: {
|
|
||||||
toggleAccount: '切换账号',
|
|
||||||
authHook: '权限钩子函数 `hasAuth`',
|
|
||||||
superAdminVisible: '超级管理员可见',
|
|
||||||
adminVisible: '管理员可见',
|
|
||||||
adminOrUserVisible: '管理员和用户可见'
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
repeatedErrorOccurOnce: '重复请求错误只出现一次',
|
|
||||||
repeatedError: '重复请求错误',
|
|
||||||
repeatedErrorMsg1: '自定义请求错误 1',
|
|
||||||
repeatedErrorMsg2: '自定义请求错误 2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
system: {
|
system: {
|
||||||
common: {
|
common: {
|
||||||
status: {
|
status: {
|
||||||
@@ -695,6 +629,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: '字典状态',
|
dictStatus: '字典状态',
|
||||||
dictLabel: '字典标签',
|
dictLabel: '字典标签',
|
||||||
dictValue: '字典键值',
|
dictValue: '字典键值',
|
||||||
|
colorType: '颜色类型',
|
||||||
sort: '排序',
|
sort: '排序',
|
||||||
remark: '备注',
|
remark: '备注',
|
||||||
form: {
|
form: {
|
||||||
@@ -703,6 +638,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: '请选择字典状态',
|
dictStatus: '请选择字典状态',
|
||||||
dictLabel: '请输入字典标签',
|
dictLabel: '请输入字典标签',
|
||||||
dictValue: '请输入字典键值',
|
dictValue: '请输入字典键值',
|
||||||
|
colorType: '请输入颜色类型',
|
||||||
sort: '请输入排序',
|
sort: '请输入排序',
|
||||||
remark: '请输入备注'
|
remark: '请输入备注'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
|
|||||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||||
import 'uno.css';
|
import 'uno.css';
|
||||||
import '../styles/css/global.css';
|
import '../styles/css/global.css';
|
||||||
import 'swiper/css';
|
|
||||||
import 'swiper/css/navigation';
|
|
||||||
import 'swiper/css/pagination';
|
|
||||||
|
|||||||
@@ -20,42 +20,21 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
500: () => import("@/views/_builtin/500/index.vue"),
|
500: () => import("@/views/_builtin/500/index.vue"),
|
||||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||||
login: () => import("@/views/_builtin/login/index.vue"),
|
login: () => import("@/views/_builtin/login/index.vue"),
|
||||||
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
|
|
||||||
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
|
|
||||||
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
|
|
||||||
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"),
|
|
||||||
function_request: () => import("@/views/function/request/index.vue"),
|
|
||||||
"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_rd-code": () => import("@/views/infra/rd-code/index.vue"),
|
||||||
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
||||||
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
||||||
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
||||||
metrics_worktime: () => import("@/views/metrics/worktime/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-application": () => import("@/views/personal-center/my-application/index.vue"),
|
||||||
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
|
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
|
||||||
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/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-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||||
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
|
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
|
||||||
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
"personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
|
||||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
"personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
|
||||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
"personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
|
||||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
"personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
|
||||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
|
||||||
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
|
||||||
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
|
||||||
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
|
||||||
plugin_icon: () => import("@/views/plugin/icon/index.vue"),
|
|
||||||
plugin_map: () => import("@/views/plugin/map/index.vue"),
|
|
||||||
plugin_pdf: () => import("@/views/plugin/pdf/index.vue"),
|
|
||||||
plugin_pinyin: () => import("@/views/plugin/pinyin/index.vue"),
|
|
||||||
plugin_print: () => import("@/views/plugin/print/index.vue"),
|
|
||||||
plugin_swiper: () => import("@/views/plugin/swiper/index.vue"),
|
|
||||||
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
|
|
||||||
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
|
|
||||||
plugin_video: () => import("@/views/plugin/video/index.vue"),
|
|
||||||
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
||||||
product_list: () => import("@/views/product/list/index.vue"),
|
product_list: () => import("@/views/product/list/index.vue"),
|
||||||
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
||||||
|
|||||||
@@ -39,124 +39,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
hideInMenu: true
|
hideInMenu: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'function',
|
|
||||||
path: '/function',
|
|
||||||
component: 'layout.base',
|
|
||||||
meta: {
|
|
||||||
title: 'function',
|
|
||||||
i18nKey: 'route.function',
|
|
||||||
icon: 'icon-park-outline:all-application',
|
|
||||||
order: 6
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'function_hide-child',
|
|
||||||
path: '/function/hide-child',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child',
|
|
||||||
i18nKey: 'route.function_hide-child',
|
|
||||||
icon: 'material-symbols:filter-list-off',
|
|
||||||
order: 2
|
|
||||||
},
|
|
||||||
redirect: '/function/hide-child/one',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_one',
|
|
||||||
path: '/function/hide-child/one',
|
|
||||||
component: 'view.function_hide-child_one',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_one',
|
|
||||||
i18nKey: 'route.function_hide-child_one',
|
|
||||||
icon: 'material-symbols:filter-list-off',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_three',
|
|
||||||
path: '/function/hide-child/three',
|
|
||||||
component: 'view.function_hide-child_three',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_three',
|
|
||||||
i18nKey: 'route.function_hide-child_three',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_two',
|
|
||||||
path: '/function/hide-child/two',
|
|
||||||
component: 'view.function_hide-child_two',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_two',
|
|
||||||
i18nKey: 'route.function_hide-child_two',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_multi-tab',
|
|
||||||
path: '/function/multi-tab',
|
|
||||||
component: 'view.function_multi-tab',
|
|
||||||
meta: {
|
|
||||||
title: 'function_multi-tab',
|
|
||||||
i18nKey: 'route.function_multi-tab',
|
|
||||||
icon: 'ic:round-tab',
|
|
||||||
multiTab: true,
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_tab'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_request',
|
|
||||||
path: '/function/request',
|
|
||||||
component: 'view.function_request',
|
|
||||||
meta: {
|
|
||||||
title: 'function_request',
|
|
||||||
i18nKey: 'route.function_request',
|
|
||||||
icon: 'carbon:network-overlay',
|
|
||||||
order: 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_super-page',
|
|
||||||
path: '/function/super-page',
|
|
||||||
component: 'view.function_super-page',
|
|
||||||
meta: {
|
|
||||||
title: 'function_super-page',
|
|
||||||
i18nKey: 'route.function_super-page',
|
|
||||||
icon: 'ic:round-supervisor-account',
|
|
||||||
order: 5,
|
|
||||||
roles: ['R_SUPER']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_tab',
|
|
||||||
path: '/function/tab',
|
|
||||||
component: 'view.function_tab',
|
|
||||||
meta: {
|
|
||||||
title: 'function_tab',
|
|
||||||
i18nKey: 'route.function_tab',
|
|
||||||
icon: 'ic:round-tab',
|
|
||||||
order: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_toggle-auth',
|
|
||||||
path: '/function/toggle-auth',
|
|
||||||
component: 'view.function_toggle-auth',
|
|
||||||
meta: {
|
|
||||||
title: 'function_toggle-auth',
|
|
||||||
i18nKey: 'route.function_toggle-auth',
|
|
||||||
icon: 'ic:round-construction',
|
|
||||||
order: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'iframe-page',
|
name: 'iframe-page',
|
||||||
path: '/iframe-page/:url',
|
path: '/iframe-page/:url',
|
||||||
@@ -292,14 +174,14 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'personal-center_my-monthly',
|
name: 'personal-center_my-item',
|
||||||
path: '/personal-center/my-monthly',
|
path: '/personal-center/my-item',
|
||||||
component: 'view.personal-center_my-monthly',
|
component: 'view.personal-center_my-item',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'personal-center_my-monthly',
|
title: 'personal-center_my-item',
|
||||||
i18nKey: 'route.personal-center_my-monthly',
|
i18nKey: 'route.personal-center_my-item',
|
||||||
icon: 'mdi:calendar-month-outline',
|
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||||
order: 2,
|
order: 1,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -328,14 +210,14 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'personal-center_my-weekly',
|
name: 'personal-center_overtime-application',
|
||||||
path: '/personal-center/my-weekly',
|
path: '/personal-center/overtime-application',
|
||||||
component: 'view.personal-center_my-weekly',
|
component: 'view.personal-center_overtime-application',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'personal-center_my-weekly',
|
title: 'personal-center_overtime-application',
|
||||||
i18nKey: 'route.personal-center_my-weekly',
|
i18nKey: 'route.personal-center_overtime-application',
|
||||||
icon: 'mdi:calendar-week-outline',
|
icon: 'mdi:clock-plus-outline',
|
||||||
order: 1,
|
order: 6,
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -347,226 +229,56 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
title: 'personal-center_pending-approval',
|
title: 'personal-center_pending-approval',
|
||||||
i18nKey: 'route.personal-center_pending-approval',
|
i18nKey: 'route.personal-center_pending-approval',
|
||||||
icon: 'mdi:check-decagram-outline',
|
icon: 'mdi:check-decagram-outline',
|
||||||
order: 5,
|
|
||||||
keepAlive: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin',
|
|
||||||
path: '/plugin',
|
|
||||||
component: 'layout.base',
|
|
||||||
meta: {
|
|
||||||
title: '插件示例',
|
|
||||||
i18nKey: 'route.plugin',
|
|
||||||
order: 7,
|
order: 7,
|
||||||
icon: 'clarity:plugin-line'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'plugin_barcode',
|
|
||||||
path: '/plugin/barcode',
|
|
||||||
component: 'view.plugin_barcode',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_barcode',
|
|
||||||
i18nKey: 'route.plugin_barcode',
|
|
||||||
icon: 'ic:round-barcode'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts',
|
|
||||||
path: '/plugin/charts',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts',
|
|
||||||
i18nKey: 'route.plugin_charts',
|
|
||||||
icon: 'mdi:chart-areaspline'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_antv',
|
|
||||||
path: '/plugin/charts/antv',
|
|
||||||
component: 'view.plugin_charts_antv',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_antv',
|
|
||||||
i18nKey: 'route.plugin_charts_antv',
|
|
||||||
icon: 'hugeicons:flow-square'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_echarts',
|
|
||||||
path: '/plugin/charts/echarts',
|
|
||||||
component: 'view.plugin_charts_echarts',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_echarts',
|
|
||||||
i18nKey: 'route.plugin_charts_echarts',
|
|
||||||
icon: 'simple-icons:apacheecharts'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_vchart',
|
|
||||||
path: '/plugin/charts/vchart',
|
|
||||||
component: 'view.plugin_charts_vchart',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_vchart',
|
|
||||||
i18nKey: 'route.plugin_charts_vchart',
|
|
||||||
localIcon: 'visactor'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_copy',
|
|
||||||
path: '/plugin/copy',
|
|
||||||
component: 'view.plugin_copy',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_copy',
|
|
||||||
i18nKey: 'route.plugin_copy',
|
|
||||||
icon: 'mdi:clipboard-outline'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_excel',
|
|
||||||
path: '/plugin/excel',
|
|
||||||
component: 'view.plugin_excel',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_excel',
|
|
||||||
i18nKey: 'route.plugin_excel',
|
|
||||||
icon: 'ri:file-excel-2-line',
|
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt',
|
name: 'personal-center_work-report',
|
||||||
path: '/plugin/gantt',
|
path: '/personal-center/work-report',
|
||||||
|
component: 'view.personal-center_work-report',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt',
|
title: 'personal-center_work-report',
|
||||||
i18nKey: 'route.plugin_gantt',
|
i18nKey: 'route.personal-center_work-report',
|
||||||
icon: 'ant-design:bar-chart-outlined'
|
icon: 'mdi:file-chart-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt_dhtmlx',
|
name: 'personal-center_work-report_monthly',
|
||||||
path: '/plugin/gantt/dhtmlx',
|
path: '/personal-center/work-report/monthly',
|
||||||
component: 'view.plugin_gantt_dhtmlx',
|
component: 'view.personal-center_work-report_monthly',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt_dhtmlx',
|
title: 'personal-center_work-report_monthly',
|
||||||
i18nKey: 'route.plugin_gantt_dhtmlx',
|
i18nKey: 'route.personal-center_work-report_monthly',
|
||||||
icon: 'gridicons:posts'
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt_vtable',
|
name: 'personal-center_work-report_project',
|
||||||
path: '/plugin/gantt/vtable',
|
path: '/personal-center/work-report/project',
|
||||||
component: 'view.plugin_gantt_vtable',
|
component: 'view.personal-center_work-report_project',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt_vtable',
|
title: 'personal-center_work-report_project',
|
||||||
i18nKey: 'route.plugin_gantt_vtable',
|
i18nKey: 'route.personal-center_work-report_project',
|
||||||
localIcon: 'visactor'
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_work-report_weekly',
|
||||||
|
path: '/personal-center/work-report/weekly',
|
||||||
|
component: 'view.personal-center_work-report_weekly',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_work-report_weekly',
|
||||||
|
i18nKey: 'route.personal-center_work-report_weekly',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_icon',
|
|
||||||
path: '/plugin/icon',
|
|
||||||
component: 'view.plugin_icon',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_icon',
|
|
||||||
i18nKey: 'route.plugin_icon',
|
|
||||||
localIcon: 'custom-icon'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_map',
|
|
||||||
path: '/plugin/map',
|
|
||||||
component: 'view.plugin_map',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_map',
|
|
||||||
i18nKey: 'route.plugin_map',
|
|
||||||
icon: 'mdi:map'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_pdf',
|
|
||||||
path: '/plugin/pdf',
|
|
||||||
component: 'view.plugin_pdf',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_pdf',
|
|
||||||
i18nKey: 'route.plugin_pdf',
|
|
||||||
icon: 'uiw:file-pdf'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_pinyin',
|
|
||||||
path: '/plugin/pinyin',
|
|
||||||
component: 'view.plugin_pinyin',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_pinyin',
|
|
||||||
i18nKey: 'route.plugin_pinyin',
|
|
||||||
icon: 'entypo-social:google-hangouts'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_print',
|
|
||||||
path: '/plugin/print',
|
|
||||||
component: 'view.plugin_print',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_print',
|
|
||||||
i18nKey: 'route.plugin_print',
|
|
||||||
icon: 'mdi:printer'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_swiper',
|
|
||||||
path: '/plugin/swiper',
|
|
||||||
component: 'view.plugin_swiper',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_swiper',
|
|
||||||
i18nKey: 'route.plugin_swiper',
|
|
||||||
icon: 'simple-icons:swiper'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_tables',
|
|
||||||
path: '/plugin/tables',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_tables',
|
|
||||||
i18nKey: 'route.plugin_tables',
|
|
||||||
icon: 'icon-park-outline:table'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'plugin_tables_vtable',
|
|
||||||
path: '/plugin/tables/vtable',
|
|
||||||
component: 'view.plugin_tables_vtable',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_tables_vtable',
|
|
||||||
i18nKey: 'route.plugin_tables_vtable',
|
|
||||||
localIcon: 'visactor'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_typeit',
|
|
||||||
path: '/plugin/typeit',
|
|
||||||
component: 'view.plugin_typeit',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_typeit',
|
|
||||||
i18nKey: 'route.plugin_typeit',
|
|
||||||
icon: 'mdi:typewriter'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_video',
|
|
||||||
path: '/plugin/video',
|
|
||||||
component: 'view.plugin_video',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_video',
|
|
||||||
i18nKey: 'route.plugin_video',
|
|
||||||
icon: 'mdi:video'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,16 +170,6 @@ const routeMap: RouteMap = {
|
|||||||
"403": "/403",
|
"403": "/403",
|
||||||
"404": "/404",
|
"404": "/404",
|
||||||
"500": "/500",
|
"500": "/500",
|
||||||
"function": "/function",
|
|
||||||
"function_hide-child": "/function/hide-child",
|
|
||||||
"function_hide-child_one": "/function/hide-child/one",
|
|
||||||
"function_hide-child_three": "/function/hide-child/three",
|
|
||||||
"function_hide-child_two": "/function/hide-child/two",
|
|
||||||
"function_multi-tab": "/function/multi-tab",
|
|
||||||
"function_request": "/function/request",
|
|
||||||
"function_super-page": "/function/super-page",
|
|
||||||
"function_tab": "/function/tab",
|
|
||||||
"function_toggle-auth": "/function/toggle-auth",
|
|
||||||
"iframe-page": "/iframe-page/:url",
|
"iframe-page": "/iframe-page/:url",
|
||||||
"infra": "/infra",
|
"infra": "/infra",
|
||||||
"infra_rd-code": "/infra/rd-code",
|
"infra_rd-code": "/infra/rd-code",
|
||||||
@@ -191,32 +181,15 @@ const routeMap: RouteMap = {
|
|||||||
"metrics_worktime": "/metrics/worktime",
|
"metrics_worktime": "/metrics/worktime",
|
||||||
"personal-center": "/personal-center",
|
"personal-center": "/personal-center",
|
||||||
"personal-center_my-application": "/personal-center/my-application",
|
"personal-center_my-application": "/personal-center/my-application",
|
||||||
"personal-center_my-monthly": "/personal-center/my-monthly",
|
"personal-center_my-item": "/personal-center/my-item",
|
||||||
"personal-center_my-performance": "/personal-center/my-performance",
|
"personal-center_my-performance": "/personal-center/my-performance",
|
||||||
"personal-center_my-profile": "/personal-center/my-profile",
|
"personal-center_my-profile": "/personal-center/my-profile",
|
||||||
"personal-center_my-weekly": "/personal-center/my-weekly",
|
"personal-center_overtime-application": "/personal-center/overtime-application",
|
||||||
"personal-center_pending-approval": "/personal-center/pending-approval",
|
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||||
"plugin": "/plugin",
|
"personal-center_work-report": "/personal-center/work-report",
|
||||||
"plugin_barcode": "/plugin/barcode",
|
"personal-center_work-report_monthly": "/personal-center/work-report/monthly",
|
||||||
"plugin_charts": "/plugin/charts",
|
"personal-center_work-report_project": "/personal-center/work-report/project",
|
||||||
"plugin_charts_antv": "/plugin/charts/antv",
|
"personal-center_work-report_weekly": "/personal-center/work-report/weekly",
|
||||||
"plugin_charts_echarts": "/plugin/charts/echarts",
|
|
||||||
"plugin_charts_vchart": "/plugin/charts/vchart",
|
|
||||||
"plugin_copy": "/plugin/copy",
|
|
||||||
"plugin_excel": "/plugin/excel",
|
|
||||||
"plugin_gantt": "/plugin/gantt",
|
|
||||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
|
||||||
"plugin_gantt_vtable": "/plugin/gantt/vtable",
|
|
||||||
"plugin_icon": "/plugin/icon",
|
|
||||||
"plugin_map": "/plugin/map",
|
|
||||||
"plugin_pdf": "/plugin/pdf",
|
|
||||||
"plugin_pinyin": "/plugin/pinyin",
|
|
||||||
"plugin_print": "/plugin/print",
|
|
||||||
"plugin_swiper": "/plugin/swiper",
|
|
||||||
"plugin_tables": "/plugin/tables",
|
|
||||||
"plugin_tables_vtable": "/plugin/tables/vtable",
|
|
||||||
"plugin_typeit": "/plugin/typeit",
|
|
||||||
"plugin_video": "/plugin/video",
|
|
||||||
"product": "/product",
|
"product": "/product",
|
||||||
"product_dashboard": "/product/dashboard",
|
"product_dashboard": "/product/dashboard",
|
||||||
"product_list": "/product/list",
|
"product_list": "/product/list",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
import { request } from '../request';
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||||
|
|
||||||
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
||||||
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
||||||
@@ -15,6 +16,52 @@ function createBatchDeleteQuery(ids: number[]) {
|
|||||||
return query.toString();
|
return query.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
|
||||||
|
colorType?: string | null;
|
||||||
|
color_type?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
|
||||||
|
list: DictDataResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
|
||||||
|
colorType?: string | null;
|
||||||
|
color_type?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
|
||||||
|
|
||||||
|
function normalizeColorType(value?: string | null) {
|
||||||
|
return value?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
|
||||||
|
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
|
||||||
|
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSaveDictDataRequest(data: Api.Dict.SaveDictDataParams) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
colorType: normalizeColorType(data.colorType),
|
||||||
|
remark: data.remark?.trim() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取字典类型分页 */
|
/** 获取字典类型分页 */
|
||||||
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
|
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
||||||
@@ -60,20 +107,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 获取字典数据分页 */
|
/** 获取字典数据分页 */
|
||||||
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
const result = await request<DictDataPageResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/page`,
|
url: `${DICT_DATA_PREFIX}/page`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return result as unknown as Awaited<ReturnType<typeof request<Api.Dict.PageResult<Api.Dict.DictData>>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
...result.data,
|
||||||
|
list: result.data.list.map(normalizeDictData)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取前端运行时字典缓存 */
|
/** 获取前端运行时字典缓存 */
|
||||||
export function fetchGetFrontendDictCache() {
|
export async function fetchGetFrontendDictCache() {
|
||||||
return request<Api.Dict.FrontendDictCache>({
|
const result = await request<FrontendDictCacheResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<FrontendDictCacheResponse>,
|
||||||
|
data =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(data).map(([dictType, list]) => [dictType, list.map(normalizeFrontendDictData)])
|
||||||
|
) as Api.Dict.FrontendDictCache
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建字典数据 */
|
/** 创建字典数据 */
|
||||||
@@ -81,7 +148,7 @@ export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
|
|||||||
return request<number>({
|
return request<number>({
|
||||||
url: `${DICT_DATA_PREFIX}/create`,
|
url: `${DICT_DATA_PREFIX}/create`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data
|
data: toSaveDictDataRequest(data)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +157,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
|
|||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
url: `${DICT_DATA_PREFIX}/update`,
|
url: `${DICT_DATA_PREFIX}/update`,
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data
|
data: toSaveDictDataRequest(data)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +179,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 通过岗位编码获取该字典的所有字典数据 */
|
/** 通过岗位编码获取该字典的所有字典数据 */
|
||||||
export function fetchGetDictDataByCode(code: string) {
|
export async function fetchGetDictDataByCode(code: string) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
const result = await request<DictDataPageResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeDictData)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ export * from './auth';
|
|||||||
export * from './dict';
|
export * from './dict';
|
||||||
export * from './file';
|
export * from './file';
|
||||||
export * from './infra';
|
export * from './infra';
|
||||||
|
export * from './notice';
|
||||||
|
export * from './notify-message';
|
||||||
export * from './object-context';
|
export * from './object-context';
|
||||||
|
export * from './overtime-application';
|
||||||
|
export * from './personal-item';
|
||||||
export * from './product';
|
export * from './product';
|
||||||
export * from './project';
|
export * from './project';
|
||||||
|
export * from './project-group';
|
||||||
export * from './project-shared';
|
export * from './project-shared';
|
||||||
export * from './route';
|
export * from './route';
|
||||||
export * from './system-manage';
|
export * from './system-manage';
|
||||||
|
export * from './work-report';
|
||||||
|
|||||||
28
src/service/api/notice.ts
Normal file
28
src/service/api/notice.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const NOTICE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notice`;
|
||||||
|
|
||||||
|
type NoticeResponse = Omit<Api.Notice.Notice, 'id'> & {
|
||||||
|
id: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNotice(data: NoticeResponse): Api.Notice.Notice {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: normalizeStringId(data.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取最近公告(status=0,按 id 倒序;登录即可,工作台公告卡片用) */
|
||||||
|
export async function fetchGetRecentNotices(size?: number) {
|
||||||
|
const result = await request<NoticeResponse[]>({
|
||||||
|
url: `${NOTICE_PREFIX}/recent`,
|
||||||
|
method: 'get',
|
||||||
|
params: { size },
|
||||||
|
...safeJsonRequestConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<NoticeResponse[]>, data => data.map(normalizeNotice));
|
||||||
|
}
|
||||||
60
src/service/api/notify-message.ts
Normal file
60
src/service/api/notify-message.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
|
||||||
|
|
||||||
|
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id'> & {
|
||||||
|
id: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
|
||||||
|
list: NotifyMessageResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: normalizeStringId(data.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户未读站内信数量(铃铛红点轮询用) */
|
||||||
|
export function fetchGetUnreadNotifyCount() {
|
||||||
|
return request<number>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/get-unread-count`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页获取我的站内信(消息列表唯一数据源;未读传 readStatus=false、已读传 true) */
|
||||||
|
export async function fetchGetMyNotifyMessagePage(params: Api.NotifyMessage.MyPageParams) {
|
||||||
|
const result = await request<MyNotifyMessagePageResponse>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/my-page`,
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
...safeJsonRequestConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyNotifyMessagePageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeNotifyMessage)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量标记站内信已读(后端幂等:重复提交、非本人条目均安全) */
|
||||||
|
export function fetchUpdateNotifyMessageRead(ids: string[]) {
|
||||||
|
// 后端约定 ids 逗号分隔
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/update-read?ids=${ids.join(',')}`,
|
||||||
|
method: 'put'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户全部站内信标记已读 */
|
||||||
|
export function fetchUpdateAllNotifyMessageRead() {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/update-all-read`,
|
||||||
|
method: 'put'
|
||||||
|
});
|
||||||
|
}
|
||||||
298
src/service/api/overtime-application.ts
Normal file
298
src/service/api/overtime-application.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
|
||||||
|
type OvertimeApplicationResponse = Omit<
|
||||||
|
Api.OvertimeApplication.OvertimeApplication,
|
||||||
|
'id' | 'applicantId' | 'approverId' | 'overtimeDate' | 'allowEdit' | 'terminal'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
applicantId: StringIdResponse;
|
||||||
|
approverId: StringIdResponse;
|
||||||
|
overtimeDate: ProjectLocalDateValue;
|
||||||
|
allowEdit?: boolean | number | string | null;
|
||||||
|
terminal?: boolean | number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeApplicationPageResult, 'total' | 'list'> & {
|
||||||
|
total: number | string;
|
||||||
|
list: OvertimeApplicationResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OvertimeApplicationApprovalRecordResponse = Omit<
|
||||||
|
Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
|
||||||
|
'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
overtimeApplicationId: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotal(total: number | string) {
|
||||||
|
const value = Number(total);
|
||||||
|
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOvertimeApplication(
|
||||||
|
response: OvertimeApplicationResponse
|
||||||
|
): Api.OvertimeApplication.OvertimeApplication {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
applicantId: normalizeStringId(response.applicantId),
|
||||||
|
approverId: normalizeStringId(response.approverId),
|
||||||
|
overtimeDate: normalizeProjectLocalDate(response.overtimeDate) ?? '',
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
approvalComment: response.approvalComment ?? null,
|
||||||
|
approvalTime: response.approvalTime ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalRecord(
|
||||||
|
response: OvertimeApplicationApprovalRecordResponse
|
||||||
|
): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
query.append('pageNo', String(params.pageNo ?? 1));
|
||||||
|
query.append('pageSize', String(params.pageSize ?? 10));
|
||||||
|
|
||||||
|
if (params.keyword) {
|
||||||
|
query.append('keyword', params.keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.applicantName) {
|
||||||
|
query.append('applicantName', params.applicantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.approverId) {
|
||||||
|
query.append('approverId', params.approverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.approverName) {
|
||||||
|
query.append('approverName', params.approverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.statusCode) {
|
||||||
|
query.append('statusCode', params.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.overtimeDate?.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('overtimeDate', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
params.createTime?.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('createTime', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSaveRequest(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||||
|
return {
|
||||||
|
overtimeDate: data.overtimeDate,
|
||||||
|
overtimeDuration: data.overtimeDuration,
|
||||||
|
overtimeReason: data.overtimeReason.trim(),
|
||||||
|
overtimeContent: data.overtimeContent.trim(),
|
||||||
|
approverId: data.approverId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatusActionRequest(data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||||
|
return {
|
||||||
|
reason: data.reason?.trim() || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationPage(
|
||||||
|
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||||
|
) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
const result = await request<OvertimeApplicationPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${OVERTIME_APPLICATION_PREFIX}/page?${query}` : `${OVERTIME_APPLICATION_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeOvertimeApplication)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationApprovalPage(
|
||||||
|
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||||
|
) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
const result = await request<OvertimeApplicationPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query
|
||||||
|
? `${OVERTIME_APPLICATION_PREFIX}/approval-page?${query}`
|
||||||
|
: `${OVERTIME_APPLICATION_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeOvertimeApplication)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationDetail(id: string) {
|
||||||
|
const result = await request<OvertimeApplicationResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationResponse>, normalizeOvertimeApplication);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateOvertimeApplication(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: OVERTIME_APPLICATION_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateRejectedOvertimeApplication(
|
||||||
|
id: string,
|
||||||
|
data: Api.OvertimeApplication.SaveOvertimeApplicationParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toSaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchApproveOvertimeApplication(
|
||||||
|
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||||
|
) {
|
||||||
|
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchRejectOvertimeApplication(
|
||||||
|
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||||
|
) {
|
||||||
|
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteOvertimeApplication(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
|
||||||
|
const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationStatusDict() {
|
||||||
|
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
|
||||||
|
data => data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${OVERTIME_APPLICATION_PREFIX}/export?${query}` : `${OVERTIME_APPLICATION_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
880
src/service/api/personal-item.ts
Normal file
880
src/service/api/personal-item.ts
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -106,13 +106,34 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
|
||||||
|
/** 后端 overview-summary 升级(total/items)灰度期间可能缺省,适配层兜底 */
|
||||||
|
total?: number | null;
|
||||||
|
items?: Api.Product.OverviewStatusItem[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 归一化产品概览统计:total/items 兜底,保证业务层拿到完整结构 */
|
||||||
|
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
statusCounts: data.statusCounts ?? {},
|
||||||
|
total: data.total ?? 0,
|
||||||
|
items: data.items ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取产品入口页概览统计 */
|
/** 获取产品入口页概览统计 */
|
||||||
export function fetchGetProductOverviewSummary() {
|
export async function fetchGetProductOverviewSummary() {
|
||||||
return request<Api.Product.ProductOverviewSummary>({
|
const result = await request<ProductOverviewSummaryResponse>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${PRODUCT_PREFIX}/overview-summary`,
|
url: `${PRODUCT_PREFIX}/overview-summary`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
|
||||||
|
normalizeProductOverviewSummary
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取产品详情 */
|
/** 获取产品详情 */
|
||||||
@@ -205,6 +226,41 @@ type RequirementResponse = Omit<
|
|||||||
};
|
};
|
||||||
|
|
||||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||||
|
type RequirementReviewResponse = Omit<
|
||||||
|
Api.Product.RequirementReview,
|
||||||
|
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
requirementId: string | number;
|
||||||
|
operatorId: string | number;
|
||||||
|
attendees?: Array<{
|
||||||
|
userId: string | number;
|
||||||
|
nickname: string;
|
||||||
|
}>;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardSummaryResponse = {
|
||||||
|
total?: number | string | null;
|
||||||
|
todo?: number | string | null;
|
||||||
|
pendingClaim?: number | string | null;
|
||||||
|
pendingReview?: number | string | null;
|
||||||
|
pendingDispatch?: number | string | null;
|
||||||
|
completed?: number | string | null;
|
||||||
|
completionRate?: number | string | null;
|
||||||
|
highPriorityTodo?: number | string | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardRecentChangeResponse = Omit<
|
||||||
|
Api.Product.ProductRequirementDashboardRecentChange,
|
||||||
|
'id' | 'requirementId' | 'operatorUserId'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
requirementId?: string | number | null;
|
||||||
|
operatorUserId?: string | number | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardResponse = {
|
||||||
|
summary?: ProductRequirementDashboardSummaryResponse | null;
|
||||||
|
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||||
fileId?: string | number;
|
fileId?: string | number;
|
||||||
@@ -242,6 +298,51 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview {
|
||||||
|
return {
|
||||||
|
...review,
|
||||||
|
id: normalizeStringId(review.id),
|
||||||
|
requirementId: normalizeStringId(review.requirementId),
|
||||||
|
operatorId: normalizeStringId(review.operatorId),
|
||||||
|
attendees: review.attendees?.map(item => ({
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId)
|
||||||
|
})),
|
||||||
|
attachments: normalizeAttachments(review.attachments)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardCount(value: number | string | null | undefined) {
|
||||||
|
const count = Number(value ?? 0);
|
||||||
|
|
||||||
|
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProductRequirementDashboard(
|
||||||
|
data: ProductRequirementDashboardResponse
|
||||||
|
): Api.Product.ProductRequirementDashboard {
|
||||||
|
const summary = data.summary ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
total: normalizeDashboardCount(summary.total),
|
||||||
|
todo: normalizeDashboardCount(summary.todo),
|
||||||
|
pendingClaim: normalizeDashboardCount(summary.pendingClaim),
|
||||||
|
pendingReview: normalizeDashboardCount(summary.pendingReview),
|
||||||
|
pendingDispatch: normalizeDashboardCount(summary.pendingDispatch),
|
||||||
|
completed: normalizeDashboardCount(summary.completed),
|
||||||
|
completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)),
|
||||||
|
highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo)
|
||||||
|
},
|
||||||
|
recentChanges: (data.recentChanges ?? []).map(item => ({
|
||||||
|
...item,
|
||||||
|
id: normalizeStringId(item.id),
|
||||||
|
requirementId: normalizeNullableStringId(item.requirementId),
|
||||||
|
operatorUserId: normalizeNullableStringId(item.operatorUserId)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取需求分页列表 */
|
/** 获取需求分页列表 */
|
||||||
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
||||||
const result = await request<RequirementPageResponse>({
|
const result = await request<RequirementPageResponse>({
|
||||||
@@ -337,17 +438,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
|
|||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭需求 */
|
|
||||||
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
|
|
||||||
return request<boolean>({
|
|
||||||
...safeJsonRequestConfig,
|
|
||||||
url: `${REQUIREMENT_PREFIX}/close`,
|
|
||||||
method: 'post',
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取需求可执行的状态动作列表 */
|
/** 获取需求可执行的状态动作列表 */
|
||||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||||
@@ -379,16 +469,43 @@ export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Produ
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求生命周期信息 */
|
/** 提交产品需求评审 */
|
||||||
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
|
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||||
const result = await request<Api.Product.RequirementLifecycleInfo>({
|
const result = await request<string | number>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${REQUIREMENT_PREFIX}/lifecycle`,
|
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||||
method: 'get',
|
method: 'post',
|
||||||
params: { requirementId, productId }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求所有状态字典 */
|
/** 获取需求所有状态字典 */
|
||||||
@@ -402,18 +519,7 @@ export async function fetchGetRequirementStatusDict() {
|
|||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求终止态状态字典 */
|
/** 判断产品需求是否已指派并生成项目需求 */
|
||||||
export async function fetchGetRequirementTerminalStatusDict() {
|
|
||||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
|
||||||
...safeJsonRequestConfig,
|
|
||||||
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
|
|
||||||
method: 'get'
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断产品需求是否已分流生成项目需求 */
|
|
||||||
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -423,7 +529,7 @@ export async function fetchHasDispatchedProjectRequirement(requirementId: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量判断产品需求是否已分流生成项目需求 */
|
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||||
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||||
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
|
|||||||
62
src/service/api/project-group.ts
Normal file
62
src/service/api/project-group.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
import { type ProjectResponse, normalizeProject } from './project';
|
||||||
|
|
||||||
|
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* group-page 原始响应。
|
||||||
|
* 组级 managerUserId、productId:后端对小数值 Long(如 1001)仍按数字返回,需 String() 归一;
|
||||||
|
* projects 字段与 page 接口项目行完全一致,复用 ProjectResponse / normalizeProject。
|
||||||
|
*/
|
||||||
|
type ProjectGroupResponse = Omit<Api.Project.ProjectGroup, 'productId' | 'managerUserId' | 'projects'> & {
|
||||||
|
productId?: string | number | null;
|
||||||
|
managerUserId?: string | number | null;
|
||||||
|
projects: ProjectResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectGroupPageResponse = Omit<Api.Project.ProjectGroupPageResult, 'list'> & {
|
||||||
|
list: ProjectGroupResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 归一化分组:组级 ID String 化,组内项目复用 normalizeProject(id/managerUserId/productId/日期统一口径) */
|
||||||
|
function normalizeProjectGroup(group: ProjectGroupResponse): Api.Project.ProjectGroup {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
productId: normalizeNullableStringId(group.productId),
|
||||||
|
managerUserId: normalizeNullableStringId(group.managerUserId),
|
||||||
|
projects: Array.isArray(group.projects) ? group.projects.map(normalizeProject) : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目列表「按产品分组」分页。
|
||||||
|
*
|
||||||
|
* 后端契约见《项目列表产品分组-前端API-2026-06-10》:
|
||||||
|
* - pageNo/pageSize 为产品组维度分页;statusCode 不传 = 「全部」口径(后端从状态机推导,
|
||||||
|
* 当前等价 pending/active/paused/completed,不含 cancelled/archived)。
|
||||||
|
* - 组内 projects 仅返前 topN 条(默认 5),projectTotal 为该口径组内全量计数;
|
||||||
|
* 剩余项目由页面按 productId / orphanOnly + statusCodes 走 page 接口展开拉取。
|
||||||
|
* - typeCounts / hasBaseline 现状恒按「全部」口径统计,不随 statusCode 变化;其中 typeCounts 已提需求
|
||||||
|
* 改为与 projectTotal 同口径(见《2026-06-11-项目分组接口typeCounts口径-后端接口需求》),后端落地后更新本注释;
|
||||||
|
* hasBaseline = 存在非已取消的主线项目(已归档/完成也算占坑),前端直接消费、不自行推导。
|
||||||
|
*/
|
||||||
|
export async function fetchGetProjectGroupPage(params?: Api.Project.ProjectGroupSearchParams) {
|
||||||
|
const result = await request<ProjectGroupPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/group-page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectGroupPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: Array.isArray(data.list) ? data.list.map(normalizeProjectGroup) : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||||
|
|
||||||
type ProjectStatusCode = Api.Project.ProjectStatusCode;
|
type ProjectStatusCode = Api.Project.ProjectStatusCode;
|
||||||
@@ -23,6 +24,8 @@ export type ProjectExecutionResponse = Omit<
|
|||||||
| 'actualStartDate'
|
| 'actualStartDate'
|
||||||
| 'actualEndDate'
|
| 'actualEndDate'
|
||||||
| 'progressRate'
|
| 'progressRate'
|
||||||
|
| 'priority'
|
||||||
|
| 'priorityName'
|
||||||
> & {
|
> & {
|
||||||
id: StringIdResponse;
|
id: StringIdResponse;
|
||||||
projectId: StringIdResponse;
|
projectId: StringIdResponse;
|
||||||
@@ -34,6 +37,98 @@ export type ProjectExecutionResponse = Omit<
|
|||||||
actualStartDate?: ProjectLocalDateValue;
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
actualEndDate?: ProjectLocalDateValue;
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
progressRate?: number | null;
|
progressRate?: number | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
priorityName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyExecutionResponse = Omit<
|
||||||
|
Api.Project.MyExecutionItem,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'projectRequirementId'
|
||||||
|
| 'priority'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
projectRequirementId?: StringIdResponse | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
progressRate?: number | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyParticipatedProjectResponse = Omit<Api.Project.MyParticipatedProjectItem, 'id'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyOwnedProjectMemberResponse = Omit<Api.Project.MyOwnedProjectMember, 'userId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' | 'members'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
members?: MyOwnedProjectMemberResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyTaskResponse = Omit<
|
||||||
|
Api.Project.MyTaskItem,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'executionId'
|
||||||
|
| 'priority'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'createTime'
|
||||||
|
| 'parentTaskId'
|
||||||
|
| 'availableActions'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
executionId?: StringIdResponse | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
progressRate?: number | string | null;
|
||||||
|
createTime?: string | number | null;
|
||||||
|
parentTaskId?: StringIdResponse | null;
|
||||||
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
|
||||||
|
projectId?: StringIdResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
items?: TeamLoadDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadResponse = {
|
||||||
|
members?: TeamLoadMemberResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
|
||||||
|
projectId?: StringIdResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
|
||||||
|
dailyHours?: number[] | null;
|
||||||
|
distribution?: WorklogDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
items?: WorklogDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
|
||||||
|
members?: TeamWorklogWeekMemberResponse[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
||||||
@@ -108,6 +203,8 @@ export type ProjectTaskResponse = Omit<
|
|||||||
| 'executionId'
|
| 'executionId'
|
||||||
| 'parentTaskId'
|
| 'parentTaskId'
|
||||||
| 'ownerId'
|
| 'ownerId'
|
||||||
|
| 'executionOwnerId'
|
||||||
|
| 'parentTaskOwnerId'
|
||||||
| 'availableActions'
|
| 'availableActions'
|
||||||
| 'plannedStartDate'
|
| 'plannedStartDate'
|
||||||
| 'plannedEndDate'
|
| 'plannedEndDate'
|
||||||
@@ -116,12 +213,18 @@ export type ProjectTaskResponse = Omit<
|
|||||||
| 'progressRate'
|
| 'progressRate'
|
||||||
| 'assignees'
|
| 'assignees'
|
||||||
| 'attachments'
|
| 'attachments'
|
||||||
|
| 'priority'
|
||||||
|
| 'priorityName'
|
||||||
> & {
|
> & {
|
||||||
id: StringIdResponse;
|
id: StringIdResponse;
|
||||||
projectId: StringIdResponse;
|
projectId: StringIdResponse;
|
||||||
executionId: StringIdResponse;
|
executionId: StringIdResponse;
|
||||||
|
executionName?: string | null;
|
||||||
|
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
|
||||||
parentTaskId?: StringIdResponse | null;
|
parentTaskId?: StringIdResponse | null;
|
||||||
ownerId: StringIdResponse;
|
ownerId: StringIdResponse;
|
||||||
|
executionOwnerId?: StringIdResponse | null;
|
||||||
|
parentTaskOwnerId?: StringIdResponse | null;
|
||||||
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||||
plannedStartDate?: ProjectLocalDateValue;
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
plannedEndDate?: ProjectLocalDateValue;
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
@@ -131,13 +234,21 @@ export type ProjectTaskResponse = Omit<
|
|||||||
assignees?: TaskAssigneeRefResponse[] | null;
|
assignees?: TaskAssigneeRefResponse[] | null;
|
||||||
attachments?: AttachmentItemResponse[] | null;
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
totalSpentHours?: number | null;
|
totalSpentHours?: number | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
priorityName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TaskWorklogResponse = Omit<Api.Project.TaskWorklog, 'id' | 'taskId' | 'userId' | 'attachments'> & {
|
export type TaskWorklogResponse = Omit<
|
||||||
|
Api.Project.TaskWorklog,
|
||||||
|
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
|
||||||
|
> & {
|
||||||
id: StringIdResponse;
|
id: StringIdResponse;
|
||||||
taskId: StringIdResponse;
|
taskId: StringIdResponse;
|
||||||
userId: StringIdResponse;
|
userId: StringIdResponse;
|
||||||
|
difficulty?: string | null;
|
||||||
attachments?: AttachmentItemResponse[] | null;
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
startDate?: ProjectLocalDateValue;
|
||||||
|
endDate?: ProjectLocalDateValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProjectMemberResponse {
|
export interface ProjectMemberResponse {
|
||||||
@@ -207,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
|
||||||
|
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
|
||||||
|
*/
|
||||||
|
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: dayjs.Dayjs;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
parsed = dayjs(value);
|
||||||
|
} else if (/^\d+$/.test(value)) {
|
||||||
|
// 字符串形态的毫秒时间戳:dayjs 无法直接解析,先转数值(时间值非 ID,安全整数范围内)
|
||||||
|
parsed = dayjs(Number(value));
|
||||||
|
} else {
|
||||||
|
parsed = dayjs(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLifecycleActions<ActionCode extends string>(
|
export function normalizeLifecycleActions<ActionCode extends string>(
|
||||||
actions: LifecycleActionResponse<ActionCode>[] | null | undefined
|
actions: LifecycleActionResponse<ActionCode>[] | null | undefined
|
||||||
): Api.Project.LifecycleAction<ActionCode>[] {
|
): Api.Project.LifecycleAction<ActionCode>[] {
|
||||||
@@ -233,12 +366,30 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePriority(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProgressRate(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||||
return {
|
return {
|
||||||
...response,
|
...response,
|
||||||
id: normalizeStringId(response.id),
|
id: normalizeStringId(response.id),
|
||||||
projectId: normalizeStringId(response.projectId),
|
projectId: normalizeStringId(response.projectId),
|
||||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null,
|
||||||
|
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||||
ownerId: normalizeStringId(response.ownerId),
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
ownerNickname: response.ownerNickname ?? null,
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
statusName: response.statusName ?? null,
|
statusName: response.statusName ?? null,
|
||||||
@@ -250,11 +401,126 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
|
|||||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
priorityName: response.priorityName ?? null,
|
||||||
executionDesc: response.executionDesc ?? null,
|
executionDesc: response.executionDesc ?? null,
|
||||||
lastStatusReason: response.lastStatusReason ?? null
|
lastStatusReason: response.lastStatusReason ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeMyExecution(response: MyExecutionResponse): Api.Project.MyExecutionItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyParticipatedProject(
|
||||||
|
response: MyParticipatedProjectResponse
|
||||||
|
): Api.Project.MyParticipatedProjectItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
code: response.code ?? null,
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
myRole: response.myRole ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.Project.MyOwnedProjectItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
code: response.code ?? null,
|
||||||
|
myRole: response.myRole ?? null,
|
||||||
|
plannedEndDate: response.plannedEndDate ?? null,
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
...member,
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userName: member.userName ?? null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
executionId: normalizeNullableStringId(response.executionId),
|
||||||
|
executionName: response.executionName ?? null,
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
|
||||||
|
createTime: normalizeProjectDateTime(response.createTime),
|
||||||
|
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||||
|
terminal: Boolean(response.terminal),
|
||||||
|
allowEdit: Boolean(response.allowEdit),
|
||||||
|
availableActions: normalizeLifecycleActions(response.availableActions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorklogDistributionItem(
|
||||||
|
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
|
||||||
|
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
|
||||||
|
return {
|
||||||
|
projectId: normalizeNullableStringId(response.projectId),
|
||||||
|
projectName: response.projectName ?? null,
|
||||||
|
kind: response.kind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
|
||||||
|
return {
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userNickname: member.userNickname ?? '',
|
||||||
|
items: (member.items ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
count: typeof item.count === 'number' ? item.count : 0
|
||||||
|
})),
|
||||||
|
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
|
||||||
|
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
||||||
|
return {
|
||||||
|
weekStart: response.weekStart ?? '',
|
||||||
|
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
|
||||||
|
distribution: (response.distribution ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
|
||||||
|
return {
|
||||||
|
weekStart: response.weekStart ?? '',
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userNickname: member.userNickname ?? '',
|
||||||
|
items: (member.items ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
|
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
|
||||||
return {
|
return {
|
||||||
...response,
|
...response,
|
||||||
@@ -289,9 +555,17 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
|||||||
id: normalizeStringId(response.id),
|
id: normalizeStringId(response.id),
|
||||||
projectId: normalizeStringId(response.projectId),
|
projectId: normalizeStringId(response.projectId),
|
||||||
executionId: normalizeStringId(response.executionId),
|
executionId: normalizeStringId(response.executionId),
|
||||||
|
executionName: response.executionName ?? null,
|
||||||
|
executionStatusCode: response.executionStatusCode ?? null,
|
||||||
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null,
|
||||||
|
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||||
|
type: response.type ?? '',
|
||||||
ownerId: normalizeStringId(response.ownerId),
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
ownerNickname: response.ownerNickname ?? null,
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
|
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
|
||||||
|
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
|
||||||
statusName: response.statusName ?? null,
|
statusName: response.statusName ?? null,
|
||||||
terminal: Boolean(response.terminal),
|
terminal: Boolean(response.terminal),
|
||||||
allowEdit: Boolean(response.allowEdit),
|
allowEdit: Boolean(response.allowEdit),
|
||||||
@@ -301,6 +575,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
|||||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
priorityName: response.priorityName ?? null,
|
||||||
taskDesc: response.taskDesc ?? null,
|
taskDesc: response.taskDesc ?? null,
|
||||||
lastStatusReason: response.lastStatusReason ?? null,
|
lastStatusReason: response.lastStatusReason ?? null,
|
||||||
assignees:
|
assignees:
|
||||||
@@ -323,7 +599,13 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
|
|||||||
userNickname: response.userNickname ?? null,
|
userNickname: response.userNickname ?? null,
|
||||||
workContent: response.workContent ?? null,
|
workContent: response.workContent ?? null,
|
||||||
attachments: normalizeAttachments(response.attachments),
|
attachments: normalizeAttachments(response.attachments),
|
||||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ExecutionAssigneeLogResponse,
|
type ExecutionAssigneeLogResponse,
|
||||||
type ExecutionAssigneeResponse,
|
type ExecutionAssigneeResponse,
|
||||||
|
type MyExecutionResponse,
|
||||||
|
type MyOwnedProjectResponse,
|
||||||
|
type MyParticipatedProjectResponse,
|
||||||
|
type MyTaskResponse,
|
||||||
|
type MyWorklogWeekResponse,
|
||||||
type ProjectExecutionResponse,
|
type ProjectExecutionResponse,
|
||||||
type ProjectLocalDateValue,
|
type ProjectLocalDateValue,
|
||||||
type ProjectMemberResponse,
|
type ProjectMemberResponse,
|
||||||
@@ -17,21 +22,30 @@ import {
|
|||||||
type TaskAssigneeFromApiResponse,
|
type TaskAssigneeFromApiResponse,
|
||||||
type TaskAssigneeLogResponse,
|
type TaskAssigneeLogResponse,
|
||||||
type TaskWorklogResponse,
|
type TaskWorklogResponse,
|
||||||
|
type TeamLoadResponse,
|
||||||
|
type TeamWorklogWeekResponse,
|
||||||
getProjectLifecycleActions,
|
getProjectLifecycleActions,
|
||||||
normalizeExecutionAssignee,
|
normalizeExecutionAssignee,
|
||||||
normalizeExecutionAssigneeLog,
|
normalizeExecutionAssigneeLog,
|
||||||
|
normalizeMyExecution,
|
||||||
|
normalizeMyOwnedProject,
|
||||||
|
normalizeMyParticipatedProject,
|
||||||
|
normalizeMyTask,
|
||||||
|
normalizeMyWorklogWeek,
|
||||||
normalizeProjectExecution,
|
normalizeProjectExecution,
|
||||||
normalizeProjectLocalDate,
|
normalizeProjectLocalDate,
|
||||||
normalizeProjectMember,
|
normalizeProjectMember,
|
||||||
normalizeProjectTask,
|
normalizeProjectTask,
|
||||||
normalizeTaskAssignee,
|
normalizeTaskAssignee,
|
||||||
normalizeTaskAssigneeLog,
|
normalizeTaskAssigneeLog,
|
||||||
normalizeTaskWorklog
|
normalizeTaskWorklog,
|
||||||
|
normalizeTeamLoad,
|
||||||
|
normalizeTeamWorklogWeek
|
||||||
} from './project-shared';
|
} from './project-shared';
|
||||||
|
|
||||||
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||||
|
|
||||||
type ProjectResponse = Omit<
|
export type ProjectResponse = Omit<
|
||||||
Api.Project.Project,
|
Api.Project.Project,
|
||||||
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
||||||
> & {
|
> & {
|
||||||
@@ -73,7 +87,7 @@ function getTaskPrefix(projectId: string, executionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 归一化项目数据 */
|
/** 归一化项目数据 */
|
||||||
function normalizeProject(project: ProjectResponse): Api.Project.Project {
|
export function normalizeProject(project: ProjectResponse): Api.Project.Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
id: normalizeStringId(project.id),
|
id: normalizeStringId(project.id),
|
||||||
@@ -130,13 +144,34 @@ export async function fetchGetProjectPage(params?: Api.Project.ProjectSearchPara
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectOverviewSummaryResponse = Omit<Api.Project.ProjectOverviewSummary, 'total' | 'items'> & {
|
||||||
|
/** 后端 overview-summary 升级(total/items)灰度期间可能缺省,适配层兜底 */
|
||||||
|
total?: number | null;
|
||||||
|
items?: Api.Project.OverviewStatusItem[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 归一化项目概览统计:total/items 兜底,保证业务层拿到完整结构 */
|
||||||
|
function normalizeProjectOverviewSummary(data: ProjectOverviewSummaryResponse): Api.Project.ProjectOverviewSummary {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
statusCounts: data.statusCounts ?? {},
|
||||||
|
total: data.total ?? 0,
|
||||||
|
items: data.items ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取项目入口页概览统计 */
|
/** 获取项目入口页概览统计 */
|
||||||
export function fetchGetProjectOverviewSummary() {
|
export async function fetchGetProjectOverviewSummary() {
|
||||||
return request<Api.Project.ProjectOverviewSummary>({
|
const result = await request<ProjectOverviewSummaryResponse>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${PROJECT_PREFIX}/overview-summary`,
|
url: `${PROJECT_PREFIX}/overview-summary`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<ProjectOverviewSummaryResponse>,
|
||||||
|
normalizeProjectOverviewSummary
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取项目详情 */
|
/** 获取项目详情 */
|
||||||
@@ -365,6 +400,105 @@ export async function fetchGetProjectExecutionPage(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「我负责的执行」(跨项目聚合,owner 隐式取当前登录用户) */
|
||||||
|
export async function fetchGetMyExecutionPage(params?: Api.Project.MyExecutionSearchParams) {
|
||||||
|
type MyExecutionPageResponse = Api.Project.PageResult<MyExecutionResponse>;
|
||||||
|
const result = await request<MyExecutionPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/executions/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyExecutionPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeMyExecution)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「我参与的项目」(成员视角,附我的角色与任务量;隐式取当前登录用户) */
|
||||||
|
export async function fetchGetMyParticipatedProjectPage(params?: Api.Project.MyProjectSearchParams) {
|
||||||
|
type MyParticipatedProjectPageResponse = Api.Project.PageResult<MyParticipatedProjectResponse>;
|
||||||
|
const result = await request<MyParticipatedProjectPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/participated/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyParticipatedProjectPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeMyParticipatedProject)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「我负责的项目」(项目负责人视角,附聚合统计与成员负载;隐式取当前登录用户) */
|
||||||
|
export async function fetchGetMyOwnedProjectPage(params?: Api.Project.MyProjectSearchParams) {
|
||||||
|
type MyOwnedProjectPageResponse = Api.Project.PageResult<MyOwnedProjectResponse>;
|
||||||
|
const result = await request<MyOwnedProjectPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/owned/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyOwnedProjectPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeMyOwnedProject)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「我的任务」(跨项目聚合,负责人/在岗协办人口径,只返回非终态;隐式取当前登录用户) */
|
||||||
|
export async function fetchGetMyTaskPage(params?: Api.Project.MyTaskSearchParams) {
|
||||||
|
type MyTaskPageResponse = Api.Project.PageResult<MyTaskResponse>;
|
||||||
|
const result = await request<MyTaskPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/tasks/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyTaskPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeMyTask)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「团队负载」(团队 = 当前用户 + 管理链路直接下级,members[0] 恒为当前用户) */
|
||||||
|
export async function fetchGetMyTeamLoad() {
|
||||||
|
const result = await request<TeamLoadResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/team-load`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TeamLoadResponse>, normalizeTeamLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「我的工时周聚合」(weekStart 传任意日期,后端归一到所在周周一;逐日工时为均摊推算值) */
|
||||||
|
export async function fetchGetMyWorklogWeek(params: Api.Project.WorklogWeekParams) {
|
||||||
|
const result = await request<MyWorklogWeekResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/worklog-week`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyWorklogWeekResponse>, normalizeMyWorklogWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工作台「团队工时周聚合」(成员集合与团队负载同口径;周标准工时后端不返回,前端落常量) */
|
||||||
|
export async function fetchGetTeamWorklogWeek(params: Api.Project.WorklogWeekParams) {
|
||||||
|
const result = await request<TeamWorklogWeekResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/me/team-worklog-week`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TeamWorklogWeekResponse>, normalizeTeamWorklogWeek);
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取项目执行状态看板 */
|
/** 获取项目执行状态看板 */
|
||||||
export function fetchGetProjectExecutionStatusBoard(
|
export function fetchGetProjectExecutionStatusBoard(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -443,6 +577,14 @@ export function fetchDeleteProjectExecution(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 执行删除预检(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(
|
export function fetchChangeProjectExecutionStatus(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -638,6 +780,14 @@ export function fetchDeleteProjectTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任务删除预检(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(
|
export function fetchChangeProjectTaskStatus(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -652,6 +802,80 @@ export function fetchChangeProjectTaskStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= 项目级跨执行任务(不带 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 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。
|
||||||
|
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403,UI 应自动切回"我的"。
|
||||||
|
*/
|
||||||
|
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>;
|
type TaskWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
|
||||||
|
|
||||||
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
|
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
|
||||||
@@ -816,6 +1040,19 @@ type ProjectRequirementResponse = Omit<
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ProjectRequirementPageResponse = Api.Project.PageResult<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'> & {
|
type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -855,10 +1092,27 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
|
|||||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||||
attachments: normalizeAttachments(requirement.attachments),
|
attachments: normalizeAttachments(requirement.attachments),
|
||||||
|
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
|
||||||
children: requirement.children?.map(normalizeProjectRequirement)
|
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(
|
function normalizeProjectRequirementModule(
|
||||||
module: ProjectRequirementModuleResponse
|
module: ProjectRequirementModuleResponse
|
||||||
): Api.Project.ProjectRequirementModule {
|
): Api.Project.ProjectRequirementModule {
|
||||||
@@ -1013,16 +1267,31 @@ export async function fetchGetProjectRequirementAllowedTransitionsBatch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取项目需求生命周期信息 */
|
/** 提交项目需求评审 */
|
||||||
export async function fetchGetProjectRequirementLifecycle(requirementId: string, projectId: string) {
|
export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) {
|
||||||
const result = await request<Api.Project.ProjectRequirementLifecycleInfo>({
|
const result = await request<string | number>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${PROJECT_REQUIREMENT_PREFIX}/lifecycle`,
|
url: `${PROJECT_REQUIREMENT_PREFIX}/review/submit`,
|
||||||
method: 'get',
|
method: 'post',
|
||||||
params: { requirementId, projectId }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementLifecycleInfo>, data => 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取项目需求状态字典 */
|
/** 获取项目需求状态字典 */
|
||||||
@@ -1036,17 +1305,6 @@ export async function fetchGetProjectRequirementStatusDict() {
|
|||||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取项目需求终态状态字典 */
|
|
||||||
export async function fetchGetProjectRequirementTerminalStatusDict() {
|
|
||||||
const result = await request<Api.Project.ProjectRequirementStatusDict[]>({
|
|
||||||
...safeJsonRequestConfig,
|
|
||||||
url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict/terminal`,
|
|
||||||
method: 'get'
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取项目需求模块树 */
|
/** 获取项目需求模块树 */
|
||||||
export async function fetchGetProjectRequirementModuleTree(projectId: string) {
|
export async function fetchGetProjectRequirementModuleTree(projectId: string) {
|
||||||
const result = await request<ProjectRequirementModuleResponse[]>({
|
const result = await request<ProjectRequirementModuleResponse[]>({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { RouteMeta } from 'vue-router';
|
|
||||||
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
||||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||||
export function fetchGetUserSimpleList() {
|
export async function fetchGetUserSimpleList() {
|
||||||
return request<UserSimpleResponse[]>({
|
return request<UserSimpleResponse[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_PREFIX}/simple-list`,
|
url: `${USER_PREFIX}/simple-list`,
|
||||||
@@ -455,6 +455,19 @@ export function fetchGetUserSimpleList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录人的直属上级 */
|
||||||
|
export async function fetchGetLoginUserDirectManager() {
|
||||||
|
return request<UserSimpleResponse | null>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${USER_PREFIX}/profile/direct-manager`,
|
||||||
|
method: 'get'
|
||||||
|
}).then(result =>
|
||||||
|
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse | null>, data =>
|
||||||
|
data ? normalizeUserSimple(data) : null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取用户分页 */
|
/** 获取用户分页 */
|
||||||
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
||||||
return request<Api.SystemManage.UserList>({
|
return request<Api.SystemManage.UserList>({
|
||||||
|
|||||||
914
src/service/api/work-report.ts
Normal file
914
src/service/api/work-report.ts
Normal file
@@ -0,0 +1,914 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
normalizeStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
const WORK_REPORT_PREFIX = `${WEB_SERVICE_PREFIX}/project/work-reports`;
|
||||||
|
const WEEKLY_PREFIX = `${WORK_REPORT_PREFIX}/weekly`;
|
||||||
|
const MONTHLY_PREFIX = `${WORK_REPORT_PREFIX}/monthly`;
|
||||||
|
const PROJECT_PREFIX = `${WORK_REPORT_PREFIX}/project`;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
type MaybeStringIdResponse = string | number | null | undefined;
|
||||||
|
|
||||||
|
type PageResponse<T> = {
|
||||||
|
total: number | string;
|
||||||
|
list: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReviewItemResponse = Omit<Api.WorkReport.Common.PersonalReportReviewItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanItemResponse = Omit<Api.WorkReport.Common.PersonalReportPlanItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeeklyTravelSegmentResponse = Omit<Api.WorkReport.Weekly.WeeklyReportTravelSegment, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeeklyReportResponse = Omit<
|
||||||
|
Api.WorkReport.Weekly.WeeklyReport,
|
||||||
|
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems' | 'travelSegments'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
reporterId: StringIdResponse;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
reviewItems?: ReviewItemResponse[] | null;
|
||||||
|
planItems?: PlanItemResponse[] | null;
|
||||||
|
travelSegments?: WeeklyTravelSegmentResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyReportResponse = Omit<
|
||||||
|
Api.WorkReport.Monthly.MonthlyReport,
|
||||||
|
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
reporterId: StringIdResponse;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
reviewItems?: ReviewItemResponse[] | null;
|
||||||
|
planItems?: PlanItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemberSnapshotResponse = Omit<Api.WorkReport.Project.WorkReportMemberSnapshot, 'userId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectReportItemResponse = Omit<Api.WorkReport.Project.ProjectReportItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectReportResponse = Omit<
|
||||||
|
Api.WorkReport.Project.ProjectReport,
|
||||||
|
'id' | 'projectId' | 'projectOwnerId' | 'projectMemberSnapshot' | 'supervisorUserId' | 'currentItems' | 'nextItems'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
projectOwnerId: StringIdResponse;
|
||||||
|
projectMemberSnapshot?: MemberSnapshotResponse[] | null;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
currentItems?: ProjectReportItemResponse[] | null;
|
||||||
|
nextItems?: ProjectReportItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApprovalRecordResponse = Omit<
|
||||||
|
Api.WorkReport.Common.WorkReportApprovalRecord,
|
||||||
|
'id' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyApprovalRecordResponse = Omit<
|
||||||
|
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
|
||||||
|
'id' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value === 1;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalConclusion(value: unknown) {
|
||||||
|
const conclusion = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (conclusion === 'approve') return 'approved';
|
||||||
|
if (conclusion === 'reject') return 'rejected';
|
||||||
|
|
||||||
|
return conclusion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateText(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const text = String(value).trim();
|
||||||
|
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
|
||||||
|
|
||||||
|
if (commaDateMatch) {
|
||||||
|
const [, year, month, day] = commaDateMatch;
|
||||||
|
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotal(total: number | string) {
|
||||||
|
const value = Number(total);
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumWorkHours(items: Array<{ workHours?: number | string | null }> = []) {
|
||||||
|
return items.reduce((sum, item) => {
|
||||||
|
const value = Number(item.workHours ?? 0);
|
||||||
|
return Number.isFinite(value) ? sum + value : sum;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReportTotalWorkHours(
|
||||||
|
totalWorkHours: number | string | null | undefined,
|
||||||
|
fallbackTotalWorkHours: number
|
||||||
|
) {
|
||||||
|
const normalizedTotal = Number(totalWorkHours ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(totalWorkHours === null ||
|
||||||
|
totalWorkHours === undefined ||
|
||||||
|
totalWorkHours === '' ||
|
||||||
|
(Number.isFinite(normalizedTotal) && normalizedTotal === 0)) &&
|
||||||
|
fallbackTotalWorkHours > 0
|
||||||
|
) {
|
||||||
|
return fallbackTotalWorkHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalWorkHours ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendValue(query: URLSearchParams, key: string, value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
query.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendArray(query: URLSearchParams, key: string, values?: Array<string | null | undefined> | null) {
|
||||||
|
values?.forEach(value => appendValue(query, key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
appendValue(query, 'pageNo', params.pageNo ?? 1);
|
||||||
|
appendValue(query, 'pageSize', params.pageSize ?? 10);
|
||||||
|
appendValue(query, 'keyword', params.keyword);
|
||||||
|
appendValue(query, 'statusCode', params.statusCode);
|
||||||
|
appendValue(query, 'supervisorName', params.supervisorName);
|
||||||
|
appendArray(query, 'periodStartDate', params.periodStartDate);
|
||||||
|
appendArray(query, 'submitTime', params.submitTime);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createBasePageQuery(params);
|
||||||
|
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
return createBasePageQuery(params).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createBasePageQuery(params);
|
||||||
|
appendValue(query, 'projectId', params.projectId);
|
||||||
|
appendValue(query, 'flag', params.flag);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReviewItem(item: ReviewItemResponse): Api.WorkReport.Common.PersonalReportReviewItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlanItem(item: PlanItemResponse): Api.WorkReport.Common.PersonalReportPlanItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklyTravelSegment(
|
||||||
|
item: WeeklyTravelSegmentResponse
|
||||||
|
): Api.WorkReport.Weekly.WeeklyReportTravelSegment {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined,
|
||||||
|
startDate: normalizeDateText(item.startDate),
|
||||||
|
endDate: normalizeDateText(item.endDate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklyReport(response: WeeklyReportResponse): Api.WorkReport.Weekly.WeeklyReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
reporterId: normalizeStringId(response.reporterId),
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
reporterDeptName: response.reporterDeptName ?? null,
|
||||||
|
reporterPostName: response.reporterPostName ?? null,
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
isBusinessTrip: normalizeBooleanFlag(response.isBusinessTrip),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
|
||||||
|
planItems: response.planItems?.map(normalizePlanItem) ?? [],
|
||||||
|
travelSegments: response.travelSegments?.map(normalizeWeeklyTravelSegment) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport.Monthly.MonthlyReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
reporterId: normalizeStringId(response.reporterId),
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
reporterDeptName: response.reporterDeptName ?? null,
|
||||||
|
reporterPostName: response.reporterPostName ?? null,
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
|
||||||
|
planItems: response.planItems?.map(normalizePlanItem) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemberSnapshot(item: MemberSnapshotResponse): Api.WorkReport.Project.WorkReportMemberSnapshot {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectReportItem(item: ProjectReportItemResponse): Api.WorkReport.Project.ProjectReportItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectReport(response: ProjectReportResponse): Api.WorkReport.Project.ProjectReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.currentItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
projectOwnerId: normalizeStringId(response.projectOwnerId),
|
||||||
|
projectMemberSnapshot: response.projectMemberSnapshot?.map(normalizeMemberSnapshot) ?? [],
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
currentItems: response.currentItems?.map(normalizeProjectReportItem) ?? [],
|
||||||
|
nextItems: response.nextItems?.map(normalizeProjectReportItem) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalRecord(response: ApprovalRecordResponse): Api.WorkReport.Common.WorkReportApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
conclusion: normalizeApprovalConclusion(response.conclusion),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonthlyApprovalRecord(
|
||||||
|
response: MonthlyApprovalRecordResponse
|
||||||
|
): Api.WorkReport.Monthly.MonthlyReportApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
conclusion: normalizeApprovalConclusion(response.conclusion),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectOption(
|
||||||
|
response: ProjectOptionResponse
|
||||||
|
): Api.WorkReport.Project.ProjectReportOwnerProjectOption {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
|
||||||
|
return {
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(mapper)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatusActionRequest(data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return {
|
||||||
|
reason: data.reason?.trim() || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalReviewItems(items: Api.WorkReport.Common.PersonalReportReviewItem[] = []) {
|
||||||
|
return items.map((item, index) => ({
|
||||||
|
itemNumber: item.itemNumber ?? index + 1,
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
workHours: item.workHours ?? 0,
|
||||||
|
contentText: item.contentText?.trim() || '',
|
||||||
|
contentJson: item.contentJson ?? null,
|
||||||
|
reflectionText: item.reflectionText?.trim() || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalPlanItems(items: Api.WorkReport.Common.PersonalReportPlanItem[] = []) {
|
||||||
|
return items.map((item, index) => ({
|
||||||
|
itemNumber: item.itemNumber ?? index + 1,
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
targetText: item.targetText?.trim() || '',
|
||||||
|
targetJson: item.targetJson ?? null,
|
||||||
|
supportNeed: item.supportNeed?.trim() || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWeeklySaveRequest(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
return {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
isBusinessTrip: data.isBusinessTrip,
|
||||||
|
reviewItems: toPersonalReviewItems(data.reviewItems),
|
||||||
|
planItems: toPersonalPlanItems(data.planItems),
|
||||||
|
travelSegments: data.isBusinessTrip
|
||||||
|
? data.travelSegments.map((item, index) => ({
|
||||||
|
sort: item.sort ?? index + 1,
|
||||||
|
startDate: item.startDate || undefined,
|
||||||
|
endDate: item.endDate || undefined,
|
||||||
|
travelDays: item.travelDays ?? 0,
|
||||||
|
location: item.location?.trim() || ''
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMonthlySaveRequest(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
return {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
reviewItems: toPersonalReviewItems(data.reviewItems),
|
||||||
|
planItems: toPersonalPlanItems(data.planItems)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectItems(items: Api.WorkReport.Project.ProjectReportItem[] = []) {
|
||||||
|
return items.map(item => ({
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
workHours: item.workHours ?? 0,
|
||||||
|
priorityCode: item.priorityCode || undefined,
|
||||||
|
progressRate: item.progressRate ?? 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectSaveRequest(data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
return {
|
||||||
|
projectId: data.projectId,
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
flag: data.flag,
|
||||||
|
projectStatusDesc: data.projectStatusDesc?.trim() || '',
|
||||||
|
projectProgressPlan: data.projectProgressPlan?.trim() || '',
|
||||||
|
projectKeyPoints: data.projectKeyPoints?.trim() || '',
|
||||||
|
projectProblems: data.projectProblems?.trim() || '',
|
||||||
|
currentItems: toProjectItems(data.currentItems),
|
||||||
|
nextItems: toProjectItems(data.nextItems)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWorkReportStatusDict() {
|
||||||
|
const result = await request<Api.WorkReport.Common.WorkReportStatusDict[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WORK_REPORT_PREFIX}/status/dict`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<WeeklyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/page?${query}` : `${WEEKLY_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeWeeklyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportApprovalPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<WeeklyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/approval-page?${query}` : `${WEEKLY_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeWeeklyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportDetail(id: string) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitWeeklyReport() {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/init`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewWeeklyReportDefaultDraft(
|
||||||
|
params: Api.WorkReport.Weekly.WeeklyReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshWeeklyReportDraft(data: Api.WorkReport.Weekly.WeeklyReportRefreshDraftParams) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateWeeklyReport(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: WEEKLY_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateWeeklyReport(id: string, data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitWeeklyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteWeeklyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<ApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportWeeklyReports(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/export?${query}` : `${WEEKLY_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportWeeklyReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Weekly.WeeklyReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${WEEKLY_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<MonthlyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/page?${query}` : `${MONTHLY_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeMonthlyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<MonthlyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/approval-page?${query}` : `${MONTHLY_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeMonthlyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportDetail(id: string) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitMonthlyReport() {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/init`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewMonthlyReportDefaultDraft(
|
||||||
|
params: Api.WorkReport.Monthly.MonthlyReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshMonthlyReportDraft(data: Api.WorkReport.Monthly.MonthlyReportRefreshDraftParams) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateMonthlyReport(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: MONTHLY_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitMonthlyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectMonthlyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteMonthlyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<MonthlyApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeMonthlyApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/export?${query}` : `${MONTHLY_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportMonthlyReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${MONTHLY_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportOwnerProjectOptions() {
|
||||||
|
const result = await request<ProjectOptionResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/owner-project-options`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectOptionResponse[]>, data =>
|
||||||
|
data.map(normalizeProjectOption)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
const result = await request<PageResponse<ProjectReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PROJECT_PREFIX}/page?${query}` : `${PROJECT_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeProjectReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportApprovalPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
const result = await request<PageResponse<ProjectReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PROJECT_PREFIX}/approval-page?${query}` : `${PROJECT_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeProjectReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportDetail(id: string) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitProjectReport(projectId: string) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/init`,
|
||||||
|
method: 'get',
|
||||||
|
params: { projectId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewProjectReportDefaultDraft(
|
||||||
|
projectId: string,
|
||||||
|
params: Api.WorkReport.Project.ProjectReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${projectId}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshProjectReportDraft(
|
||||||
|
projectId: string,
|
||||||
|
data: Api.WorkReport.Project.ProjectReportRefreshDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${projectId}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
flag: data.flag,
|
||||||
|
projectStatusDesc: data.projectStatusDesc?.trim() || '',
|
||||||
|
projectProgressPlan: data.projectProgressPlan?.trim() || '',
|
||||||
|
projectKeyPoints: data.projectKeyPoints?.trim() || '',
|
||||||
|
projectProblems: data.projectProblems?.trim() || '',
|
||||||
|
currentItems: toProjectItems(data.currentItems),
|
||||||
|
nextItems: toProjectItems(data.nextItems)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateProjectReport(data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: PROJECT_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toProjectSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateProjectReport(id: string, data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toProjectSaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitProjectReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteProjectReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<ApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportProjectReports(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${PROJECT_PREFIX}/export?${query}` : `${PROJECT_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportProjectReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${PROJECT_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 工作台聚合接口尚未开通,当前页面使用 src/views/workbench/mock.ts 的本地假数据。
|
|
||||||
// 接口契约确认后,在此处补:
|
|
||||||
// - fetchGetWorkbenchSummary (Banner 摘要 + KPI)
|
|
||||||
// - fetchGetWorkbenchTodos (我的待办)
|
|
||||||
// - fetchGetWorkbenchActivity (最近动态)
|
|
||||||
// - fetchGetWorkbenchProjects (我参与的项目)
|
|
||||||
// 全部走 src/service/request/index.ts 的统一实例,并保持 ID 字符串口径。
|
|
||||||
export {};
|
|
||||||
@@ -12,11 +12,13 @@ import type { RequestInstanceState } from './type';
|
|||||||
|
|
||||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||||
|
const REQUEST_TIMEOUT = 15 * 1000;
|
||||||
|
|
||||||
export const request = withDedupe(
|
export const request = withDedupe(
|
||||||
createFlatRequest(
|
createFlatRequest(
|
||||||
{
|
{
|
||||||
baseURL,
|
baseURL,
|
||||||
|
timeout: REQUEST_TIMEOUT,
|
||||||
headers: {
|
headers: {
|
||||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||||
}
|
}
|
||||||
@@ -126,6 +128,10 @@ export const request = withDedupe(
|
|||||||
let message = error.message;
|
let message = error.message;
|
||||||
let backendErrorCode = '';
|
let backendErrorCode = '';
|
||||||
|
|
||||||
|
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||||
|
message = '请求超时,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
// 获取后端错误信息和错误码
|
// 获取后端错误信息和错误码
|
||||||
if (error.code === BACKEND_ERROR_CODE) {
|
if (error.code === BACKEND_ERROR_CODE) {
|
||||||
message = error.response?.data?.msg || message;
|
message = error.response?.data?.msg || message;
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
// If the tab needs to be cleared,it means we don't need to redirect.
|
// If the tab needs to be cleared,it means we don't need to redirect.
|
||||||
needRedirect = false;
|
needRedirect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
|
||||||
|
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
|
||||||
|
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
|
||||||
|
await routeStore.initAuthRoute();
|
||||||
|
|
||||||
await redirectFromLogin(needRedirect);
|
await redirectFromLogin(needRedirect);
|
||||||
|
|
||||||
window.$notification?.success({
|
window.$notification?.success({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchGetFrontendDictCache } from '@/service/api';
|
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||||
import { SetupStoreId } from '@/enum';
|
import { SetupStoreId } from '@/enum';
|
||||||
|
|
||||||
type DictValue = string | number | null | undefined;
|
type DictValue = string | number | null | undefined;
|
||||||
@@ -19,6 +19,15 @@ function sortDictData(list: Api.Dict.DictData[]) {
|
|||||||
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hex 色值兜底校验:仅接受 #RRGGBB(6 位);其他格式(含 #RGB 简写 / rgb())一律视为无效回落到默认渲染
|
||||||
|
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||||
|
|
||||||
|
function normalizeColorType(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFrontendDictData(
|
function normalizeFrontendDictData(
|
||||||
dictType: string,
|
dictType: string,
|
||||||
list: Api.Dict.FrontendDictData[],
|
list: Api.Dict.FrontendDictData[],
|
||||||
@@ -31,13 +40,25 @@ function normalizeFrontendDictData(
|
|||||||
dictType: item.dictType || dictType,
|
dictType: item.dictType || dictType,
|
||||||
sort: item.sort,
|
sort: item.sort,
|
||||||
status: item.status ?? 0,
|
status: item.status ?? 0,
|
||||||
remark: null,
|
colorType: normalizeColorType(item.colorType),
|
||||||
|
remark: item.remark ?? null,
|
||||||
createTime: 0
|
createTime: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return sortDictData(normalizedList);
|
return sortDictData(normalizedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
value: String(item.value),
|
||||||
|
dictType: item.dictType || dictType,
|
||||||
|
status: item.status ?? 0,
|
||||||
|
colorType: normalizeColorType(item.colorType),
|
||||||
|
remark: item.remark ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||||
const entries = Object.entries(cache);
|
const entries = Object.entries(cache);
|
||||||
|
|
||||||
@@ -89,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
const loadedAt = ref<number | null>(null);
|
const loadedAt = ref<number | null>(null);
|
||||||
|
|
||||||
let initPromise: Promise<boolean> | null = null;
|
let initPromise: Promise<boolean> | null = null;
|
||||||
|
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||||
|
|
||||||
function resetDictCache() {
|
function resetDictCache() {
|
||||||
dictTypes.value = [];
|
dictTypes.value = [];
|
||||||
@@ -96,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
loadedAt.value = null;
|
loadedAt.value = null;
|
||||||
initialized.value = false;
|
initialized.value = false;
|
||||||
initPromise = null;
|
initPromise = null;
|
||||||
|
dictDataLoadPromises.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initDictCache(force = false) {
|
async function initDictCache(force = false) {
|
||||||
@@ -137,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
return initPromise;
|
return initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDictData(dictType: string, force = false) {
|
||||||
|
if (!dictType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized.value) {
|
||||||
|
await initDictCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && getDictData(dictType).length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = dictDataLoadPromises.get(dictType);
|
||||||
|
if (pending && !force) {
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const result = await fetchGetDictDataByCode(dictType);
|
||||||
|
|
||||||
|
if (result.error || !result.data?.list?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictDataMap.value = {
|
||||||
|
...dictDataMap.value,
|
||||||
|
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||||
|
};
|
||||||
|
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
dictDataLoadPromises.set(dictType, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||||
|
dictDataLoadPromises.delete(dictType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDictData(dictType: string, onlyEnabled = false) {
|
function getDictData(dictType: string, onlyEnabled = false) {
|
||||||
if (!dictType) {
|
if (!dictType) {
|
||||||
return [];
|
return [];
|
||||||
@@ -199,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
dictDataMap,
|
dictDataMap,
|
||||||
loadedAt,
|
loadedAt,
|
||||||
initDictCache,
|
initDictCache,
|
||||||
|
ensureDictData,
|
||||||
resetDictCache,
|
resetDictCache,
|
||||||
getDictData,
|
getDictData,
|
||||||
getDictOptions,
|
getDictOptions,
|
||||||
|
|||||||
11
src/store/modules/workbench/index.ts
Normal file
11
src/store/modules/workbench/index.ts
Normal 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 });
|
||||||
|
});
|
||||||
@@ -406,6 +406,7 @@ html .el-collapse {
|
|||||||
.business-table-action-cell {
|
.business-table-action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -416,6 +417,20 @@ html .el-collapse {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.business-table-action-icon-button {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-table-action-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.business-table-action-menu {
|
.business-table-action-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -428,6 +443,19 @@ html .el-collapse {
|
|||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.business-table-action-menu__link {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-table-action-menu__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.business-table-card-body {
|
.business-table-card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100% - 56px);
|
height: calc(100% - 56px);
|
||||||
|
|||||||
@@ -89,4 +89,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
|||||||
*
|
*
|
||||||
* If publish new version, use `overrideThemeSettings` to override certain theme settings
|
* If publish new version, use `overrideThemeSettings` to override certain theme settings
|
||||||
*/
|
*/
|
||||||
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {};
|
// 系统固定亮色主题:切换入口已全部移除,发新版时把老用户缓存的暗色设置刷回亮色
|
||||||
|
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {
|
||||||
|
themeScheme: 'light'
|
||||||
|
};
|
||||||
|
|||||||
12
src/typings/api/dict.d.ts
vendored
12
src/typings/api/dict.d.ts
vendored
@@ -47,8 +47,6 @@ declare namespace Api {
|
|||||||
id: number;
|
id: number;
|
||||||
/** dict label */
|
/** dict label */
|
||||||
label: string;
|
label: string;
|
||||||
/** sign */
|
|
||||||
sign?: string | null;
|
|
||||||
/** dict value */
|
/** dict value */
|
||||||
value: string;
|
value: string;
|
||||||
/** dict type code */
|
/** dict type code */
|
||||||
@@ -57,6 +55,8 @@ declare namespace Api {
|
|||||||
sort: number;
|
sort: number;
|
||||||
/** status: 0 enabled, 1 disabled */
|
/** status: 0 enabled, 1 disabled */
|
||||||
status: DictStatus;
|
status: DictStatus;
|
||||||
|
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||||
|
colorType?: string | null;
|
||||||
/** remark */
|
/** remark */
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
/** create time */
|
/** create time */
|
||||||
@@ -67,8 +67,6 @@ declare namespace Api {
|
|||||||
interface FrontendDictData {
|
interface FrontendDictData {
|
||||||
/** dict label */
|
/** dict label */
|
||||||
label: string;
|
label: string;
|
||||||
/** sign */
|
|
||||||
sign?: string | null;
|
|
||||||
/** dict value */
|
/** dict value */
|
||||||
value: string;
|
value: string;
|
||||||
/** display order */
|
/** display order */
|
||||||
@@ -77,6 +75,10 @@ declare namespace Api {
|
|||||||
dictType?: string;
|
dictType?: string;
|
||||||
/** status: 0 enabled, 1 disabled */
|
/** status: 0 enabled, 1 disabled */
|
||||||
status?: DictStatus;
|
status?: DictStatus;
|
||||||
|
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||||
|
colorType?: string | null;
|
||||||
|
/** 备注,可用于下拉中文释义展示 */
|
||||||
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** frontend runtime dict cache map */
|
/** frontend runtime dict cache map */
|
||||||
@@ -86,7 +88,7 @@ declare namespace Api {
|
|||||||
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||||
|
|
||||||
/** dict data save params */
|
/** dict data save params */
|
||||||
type SaveDictDataParams = Pick<DictData, 'label' | 'sign' | 'value' | 'dictType' | 'sort' | 'status'> & {
|
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status' | 'colorType'> & {
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/typings/api/notice.d.ts
vendored
Normal file
24
src/typings/api/notice.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace Notice
|
||||||
|
*
|
||||||
|
* backend api module: "notice"(通知公告)
|
||||||
|
*/
|
||||||
|
namespace Notice {
|
||||||
|
/** 公告(ID 在 API 适配层已统一为 string) */
|
||||||
|
interface Notice {
|
||||||
|
/** 公告编号 */
|
||||||
|
id: string;
|
||||||
|
/** 公告标题 */
|
||||||
|
title: string;
|
||||||
|
/** 公告类型,字典 system_notice_type */
|
||||||
|
type: number;
|
||||||
|
/** 公告内容(富文本 / 纯文本,由录入决定) */
|
||||||
|
content: string;
|
||||||
|
/** 状态:0 开启 / 1 关闭 */
|
||||||
|
status: number;
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime: string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/typings/api/notify-message.d.ts
vendored
Normal file
44
src/typings/api/notify-message.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace NotifyMessage
|
||||||
|
*
|
||||||
|
* backend api module: "notify-message"(站内信 · 我的收件箱)
|
||||||
|
*/
|
||||||
|
namespace NotifyMessage {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T = any> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站内信(铃铛 / 收件箱展示用;ID 在 API 适配层已统一为 string) */
|
||||||
|
interface NotifyMessage {
|
||||||
|
/** 站内信编号(雪花 Long,按 string 接收) */
|
||||||
|
id: string;
|
||||||
|
/** 发送人名称(模板配置的发件人显示名) */
|
||||||
|
templateNickname: string;
|
||||||
|
/** 最终消息正文(占位符已渲染,直接展示) */
|
||||||
|
templateContent: string;
|
||||||
|
/** 消息类型,字典 system_notify_template_type */
|
||||||
|
templateType: number;
|
||||||
|
/** 是否已读 */
|
||||||
|
readStatus: boolean;
|
||||||
|
/** 阅读时间;未读为 null */
|
||||||
|
readTime: string | number | null;
|
||||||
|
/** 收到时间 */
|
||||||
|
createTime: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的站内信分页查询参数 */
|
||||||
|
interface MyPageParams extends PageParams {
|
||||||
|
/** true 只看已读 / false 只看未读 / 不传 = 全部 */
|
||||||
|
readStatus?: boolean;
|
||||||
|
/** 关键字,后端对消息正文模糊匹配;不传或空串 = 不过滤 */
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/typings/api/overtime-application.d.ts
vendored
Normal file
99
src/typings/api/overtime-application.d.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
namespace OvertimeApplication {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject';
|
||||||
|
|
||||||
|
interface OvertimeApplication {
|
||||||
|
id: string;
|
||||||
|
applicantId: string;
|
||||||
|
applicantName: string;
|
||||||
|
overtimeDate: string;
|
||||||
|
overtimeDuration: string;
|
||||||
|
overtimeReason: string;
|
||||||
|
overtimeContent: string;
|
||||||
|
approverId: string;
|
||||||
|
approverName: string;
|
||||||
|
statusCode: OvertimeApplicationStatusCode;
|
||||||
|
statusName: string;
|
||||||
|
allowEdit: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
approvalComment?: string | null;
|
||||||
|
submitTime: string;
|
||||||
|
approvalTime?: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
applicantName: string;
|
||||||
|
approverId: string;
|
||||||
|
approverName: string;
|
||||||
|
statusCode: OvertimeApplicationStatusCode;
|
||||||
|
overtimeDate: string[];
|
||||||
|
createTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface OvertimeApplicationPageResult {
|
||||||
|
total: number;
|
||||||
|
list: OvertimeApplication[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveOvertimeApplicationParams {
|
||||||
|
overtimeDate: string;
|
||||||
|
overtimeDuration: string;
|
||||||
|
overtimeReason: string;
|
||||||
|
overtimeContent: string;
|
||||||
|
approverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusActionParams {
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchActionParams {
|
||||||
|
ids: string[];
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchFailItem {
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchActionResult {
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
failItems: OvertimeApplicationBatchFailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationApprovalRecord {
|
||||||
|
id: string;
|
||||||
|
overtimeApplicationId: string;
|
||||||
|
statusLogId: string;
|
||||||
|
approvalRound: number;
|
||||||
|
conclusion: string;
|
||||||
|
opinion?: string | null;
|
||||||
|
auditorUserId: string;
|
||||||
|
auditorName: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationStatusDict {
|
||||||
|
statusCode: string;
|
||||||
|
statusName: string;
|
||||||
|
sort: number;
|
||||||
|
initialFlag: boolean;
|
||||||
|
terminalFlag: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/typings/api/personal-item.d.ts
vendored
Normal file
99
src/typings/api/personal-item.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/typings/api/product.d.ts
vendored
128
src/typings/api/product.d.ts
vendored
@@ -21,10 +21,27 @@ declare namespace Api {
|
|||||||
list: T[];
|
list: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
|
||||||
|
interface OverviewStatusItem {
|
||||||
|
statusCode: string;
|
||||||
|
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
|
||||||
|
statusName: string;
|
||||||
|
count: number;
|
||||||
|
sort: number;
|
||||||
|
/** 是否终态(状态机 terminal_flag) */
|
||||||
|
terminal: boolean;
|
||||||
|
/** 是否计入"全部";当前口径无排除项恒为 true(产品列表暂无"全部"视图,按同构契约返回) */
|
||||||
|
includeInAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** 产品入口页概览统计 */
|
/** 产品入口页概览统计 */
|
||||||
interface ProductOverviewSummary {
|
interface ProductOverviewSummary {
|
||||||
/** 产品状态数量映射,key 为后端状态编码 */
|
/** 产品状态数量映射,key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
|
||||||
statusCounts: Record<string, number>;
|
statusCounts: Record<string, number>;
|
||||||
|
/** "全部"口径总数 = items 各状态 count 之和 */
|
||||||
|
total: number;
|
||||||
|
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
|
||||||
|
items: OverviewStatusItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
@@ -172,8 +189,10 @@ declare namespace Api {
|
|||||||
|
|
||||||
type ProductSearchParams = CommonType.RecordNullable<
|
type ProductSearchParams = CommonType.RecordNullable<
|
||||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
|
Pick<Product, 'directionCode' | 'managerUserId'> & {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||||
|
statusCode: string;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -256,15 +275,29 @@ declare namespace Api {
|
|||||||
// ========== 产品需求相关类型定义 ==========
|
// ========== 产品需求相关类型定义 ==========
|
||||||
/** 需求状态编码 */
|
/** 需求状态编码 */
|
||||||
type RequirementStatusCode =
|
type RequirementStatusCode =
|
||||||
| 'pending_confirm'
|
| 'pending_claim'
|
||||||
| 'pending_review'
|
| 'pending_review'
|
||||||
| 'pending_dispatch'
|
| 'pending_dispatch'
|
||||||
|
| 'reviewed'
|
||||||
|
| 'review_rejected'
|
||||||
| 'implementing'
|
| 'implementing'
|
||||||
| 'accepted'
|
| 'accepted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
| 'rejected'
|
| 'rejected'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
|
/** 需求状态动作编码 */
|
||||||
|
type RequirementStatusActionCode =
|
||||||
|
| 'claim_to_review'
|
||||||
|
| 'claim_to_dispatch'
|
||||||
|
| 'pass_review'
|
||||||
|
| 'reject_review'
|
||||||
|
| 'dispatch'
|
||||||
|
| 'cancel'
|
||||||
|
| 'accept'
|
||||||
|
| 'close'
|
||||||
|
| 'reject';
|
||||||
|
|
||||||
/** 需求来源类型 */
|
/** 需求来源类型 */
|
||||||
type RequirementSourceType = 'manual' | 'work_order';
|
type RequirementSourceType = 'manual' | 'work_order';
|
||||||
|
|
||||||
@@ -333,8 +366,6 @@ declare namespace Api {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
/** 子需求列表(树形结构) */
|
/** 子需求列表(树形结构) */
|
||||||
children?: Requirement[];
|
children?: Requirement[];
|
||||||
/** 是否为终态 */
|
|
||||||
terminal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 需求模块实体 ==========
|
// ========== 需求模块实体 ==========
|
||||||
@@ -371,27 +402,18 @@ declare namespace Api {
|
|||||||
initialFlag: boolean;
|
initialFlag: boolean;
|
||||||
/** 是否终态 */
|
/** 是否终态 */
|
||||||
terminalFlag: boolean;
|
terminalFlag: boolean;
|
||||||
|
/** 是否允许编辑 */
|
||||||
|
allowEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 需求生命周期 ==========
|
|
||||||
|
|
||||||
interface RequirementLifecycleAction {
|
interface RequirementLifecycleAction {
|
||||||
actionCode: string;
|
actionCode: RequirementStatusActionCode;
|
||||||
actionName: string;
|
actionName: string;
|
||||||
toStatusCode: string;
|
toStatusCode: string;
|
||||||
toStatusName: string;
|
toStatusName: string;
|
||||||
needReason: boolean;
|
needReason: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequirementLifecycleInfo {
|
|
||||||
statusCode: RequirementStatusCode;
|
|
||||||
statusName?: string | null;
|
|
||||||
lastStatusReason?: string | null;
|
|
||||||
terminal: boolean;
|
|
||||||
allowEdit: boolean;
|
|
||||||
availableActions: RequirementLifecycleAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequirementBatchReqVO {
|
interface RequirementBatchReqVO {
|
||||||
productId: string;
|
productId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
@@ -407,6 +429,78 @@ declare namespace Api {
|
|||||||
hasDispatched: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 请求参数类型 ==========
|
// ========== 请求参数类型 ==========
|
||||||
|
|
||||||
/** 需求分页查询参数 */
|
/** 需求分页查询参数 */
|
||||||
|
|||||||
528
src/typings/api/project.d.ts
vendored
528
src/typings/api/project.d.ts
vendored
@@ -65,7 +65,7 @@ declare namespace Api {
|
|||||||
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
/** 执行动作编码 */
|
/** 执行动作编码 */
|
||||||
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
|
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel' | 'complete';
|
||||||
|
|
||||||
/** 任务状态编码 */
|
/** 任务状态编码 */
|
||||||
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||||
@@ -96,6 +96,10 @@ declare namespace Api {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectRequirementId: string | null;
|
projectRequirementId: string | null;
|
||||||
|
/** 关联项目需求名称(service 层批量回填;未关联 = null) */
|
||||||
|
projectRequirementName: string | null;
|
||||||
|
/** 关联项目需求状态编码(pending_confirm/pending_review/implementing/accepted/closed/rejected/cancelled) */
|
||||||
|
projectRequirementStatusCode: string | null;
|
||||||
executionName: string;
|
executionName: string;
|
||||||
executionType: string | null;
|
executionType: string | null;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -110,6 +114,10 @@ declare namespace Api {
|
|||||||
actualStartDate: string | null;
|
actualStartDate: string | null;
|
||||||
actualEndDate: string | null;
|
actualEndDate: string | null;
|
||||||
progressRate: number;
|
progressRate: number;
|
||||||
|
/** 优先级字典 value(rdms_req_priority):"0" P0 / "1" P1(默认)/ "2" P2 / "3" P3,数字越小越高 */
|
||||||
|
priority: string;
|
||||||
|
/** 优先级标签预留字段;当前后端不填、永远为 null,前端按 priority 自译 */
|
||||||
|
priorityName: string | null;
|
||||||
executionDesc: string | null;
|
executionDesc: string | null;
|
||||||
lastStatusReason: string | null;
|
lastStatusReason: string | null;
|
||||||
createTime: string;
|
createTime: string;
|
||||||
@@ -212,12 +220,23 @@ declare namespace Api {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
executionId: string;
|
executionId: string;
|
||||||
|
/** 所属执行名称;跨执行查询必有,单执行查询可缺省 */
|
||||||
|
executionName?: string | null;
|
||||||
|
/** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */
|
||||||
|
executionStatusCode?: ProjectExecutionStatusCode | null;
|
||||||
parentTaskId: string | null;
|
parentTaskId: string | null;
|
||||||
|
/** 所属执行关联的项目需求 ID(透传,未关联 = null) */
|
||||||
|
projectRequirementId: string | null;
|
||||||
|
/** 所属执行关联的项目需求名称(透传,未关联 = null;跨执行查询永远为 null,前端不在跨执行视角展示) */
|
||||||
|
projectRequirementName: string | null;
|
||||||
|
/** 所属执行关联的项目需求状态编码(同上) */
|
||||||
|
projectRequirementStatusCode: string | null;
|
||||||
taskTitle: string;
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerNickname?: string | null;
|
ownerNickname?: string | null;
|
||||||
/** 所属执行的负责人 userId(按钮可见度公式用) */
|
/** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */
|
||||||
executionOwnerId: string;
|
executionOwnerId: string | null;
|
||||||
/** 父任务负责人 userId(一级任务为 null) */
|
/** 父任务负责人 userId(一级任务为 null) */
|
||||||
parentTaskOwnerId: string | null;
|
parentTaskOwnerId: string | null;
|
||||||
statusCode: ProjectTaskStatusCode;
|
statusCode: ProjectTaskStatusCode;
|
||||||
@@ -230,6 +249,10 @@ declare namespace Api {
|
|||||||
plannedEndDate: string | null;
|
plannedEndDate: string | null;
|
||||||
actualStartDate: string | null;
|
actualStartDate: string | null;
|
||||||
actualEndDate: string | null;
|
actualEndDate: string | null;
|
||||||
|
/** 优先级字典 value(rdms_req_priority):"0" P0 / "1" P1(默认)/ "2" P2 / "3" P3,数字越小越高 */
|
||||||
|
priority: string;
|
||||||
|
/** 优先级标签预留字段;当前后端不填、永远为 null,前端按 priority 自译 */
|
||||||
|
priorityName: string | null;
|
||||||
taskDesc: string | null;
|
taskDesc: string | null;
|
||||||
lastStatusReason: string | null;
|
lastStatusReason: string | null;
|
||||||
assignees?: TaskAssigneeRef[] | null;
|
assignees?: TaskAssigneeRef[] | null;
|
||||||
@@ -240,12 +263,31 @@ declare namespace Api {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行截止时间范围(基于 plannedEndDate):overdue 逾期 / 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<
|
type ProjectExecutionSearchParams = CommonType.RecordNullable<
|
||||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
executionType: string;
|
executionType: string;
|
||||||
|
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||||
|
involveUserId: string;
|
||||||
|
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
|
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||||
|
priority: string;
|
||||||
|
dueRange: ProjectExecutionDueRange;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -253,10 +295,222 @@ declare namespace Api {
|
|||||||
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
|
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
|
||||||
keyword: string;
|
keyword: string;
|
||||||
executionType: string;
|
executionType: string;
|
||||||
|
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||||
|
involveUserId: string;
|
||||||
|
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
/** 截止时间范围过滤,传入后各状态分组计数均在该范围内统计(口径同 page) */
|
||||||
|
dueRange: ProjectExecutionDueRange;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/** 工作台「我负责的执行」(跨项目)查询入参 */
|
||||||
|
type MyExecutionSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
/** 预留:单状态精确过滤,不传走后端默认口径 */
|
||||||
|
statusCode: string;
|
||||||
|
/** 预留:执行名称模糊匹配 */
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 工作台「我负责的执行」单项(跨项目聚合,owner 恒为当前登录用户) */
|
||||||
|
interface MyExecutionItem {
|
||||||
|
/** 执行 ID(雪花 ID,字符串) */
|
||||||
|
id: string;
|
||||||
|
executionName: string;
|
||||||
|
/** 所属项目 */
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
/** 执行状态编码:pending / active / paused */
|
||||||
|
statusCode: string;
|
||||||
|
/** 执行状态名称 */
|
||||||
|
statusName: string | null;
|
||||||
|
/** 优先级字典 value(rdms_req_priority,"0"~"3") */
|
||||||
|
priority: string;
|
||||||
|
/** 计划起止(YYYY-MM-DD) */
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
/** 实际起止(YYYY-MM-DD) */
|
||||||
|
actualStartDate: string | null;
|
||||||
|
actualEndDate: string | null;
|
||||||
|
/** 进度(0-100 整数) */
|
||||||
|
progressRate: number;
|
||||||
|
/** 关联项目需求 */
|
||||||
|
projectRequirementId: string | null;
|
||||||
|
projectRequirementName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「我的项目」查询入参(我参与的 / 我负责的 共用) */
|
||||||
|
type MyProjectSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
/** 预留:项目名称/编码模糊关键字,后端本期不过滤 */
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 工作台「我参与的项目」单项(成员视角,附带我的角色与任务量) */
|
||||||
|
interface MyParticipatedProjectItem {
|
||||||
|
/** 项目 ID(字符串) */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** 项目编码,可空 */
|
||||||
|
code: string | null;
|
||||||
|
/** 项目状态编码(如 active) */
|
||||||
|
statusCode: string;
|
||||||
|
/** 项目状态名称,可空 */
|
||||||
|
statusName: string | null;
|
||||||
|
/** 项目整体进度 0-100 */
|
||||||
|
progress: number;
|
||||||
|
/** 我在该项目中的角色名(多角色拼接),可空 */
|
||||||
|
myRole: string | null;
|
||||||
|
/** 我负责的任务总数(按负责人,含已完成) */
|
||||||
|
myTaskCount: number;
|
||||||
|
/** 我负责的未完成任务数 */
|
||||||
|
myPendingTaskCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「我负责的项目」成员负载子项 */
|
||||||
|
interface MyOwnedProjectMember {
|
||||||
|
/** 成员用户 ID(字符串) */
|
||||||
|
userId: string;
|
||||||
|
/** 成员姓名/昵称,可空 */
|
||||||
|
userName: string | null;
|
||||||
|
/** 该成员在本项目下进行中任务数(按负责人) */
|
||||||
|
activeTaskCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「我负责的项目」单项(项目负责人视角,附聚合统计与成员负载) */
|
||||||
|
interface MyOwnedProjectItem {
|
||||||
|
/** 项目 ID(字符串) */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** 项目编码,可空 */
|
||||||
|
code: string | null;
|
||||||
|
/** 项目整体进度 0-100 */
|
||||||
|
progress: number;
|
||||||
|
/** 我在该项目中的角色名,可空 */
|
||||||
|
myRole: string | null;
|
||||||
|
/** 项目计划结束日期 YYYY-MM-DD,可空 */
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
/** 项目下进行中执行数 */
|
||||||
|
executionCount: number;
|
||||||
|
/** 项目下进行中任务数 */
|
||||||
|
taskCount: number;
|
||||||
|
/** 项目下逾期任务数 */
|
||||||
|
overdueCount: number;
|
||||||
|
/** 项目当前有效成员数(多角色去重) */
|
||||||
|
memberCount: number;
|
||||||
|
/** 成员负载列表(无成员为 []) */
|
||||||
|
members: MyOwnedProjectMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「我的任务」(跨项目)查询入参 */
|
||||||
|
type MyTaskSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
/** 身份过滤:owner 我负责 / collaborator 我协办;缺省 = 两者并集 */
|
||||||
|
involveType: 'owner' | 'collaborator';
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 工作台「我的任务」单项(跨项目;当前用户为负责人或在岗协办人,接口只返回非终态任务) */
|
||||||
|
interface MyTaskItem {
|
||||||
|
/** 任务 ID(雪花 ID,字符串) */
|
||||||
|
id: string;
|
||||||
|
taskTitle: string;
|
||||||
|
/** 所属项目 */
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
/** 所属执行,未挂执行为 null */
|
||||||
|
executionId: string | null;
|
||||||
|
executionName: string | null;
|
||||||
|
/** 任务状态:pending / active / paused(非终态) */
|
||||||
|
statusCode: ProjectTaskStatusCode;
|
||||||
|
statusName: string | null;
|
||||||
|
/** 优先级字典 value(rdms_req_priority,"0"~"3",数字越小越高) */
|
||||||
|
priority: string;
|
||||||
|
/** 计划结束日期(YYYY-MM-DD),可空 */
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
/** 任务进度(0-100);后端定稿直接返回,无进度明确返 0 */
|
||||||
|
progressRate: number;
|
||||||
|
/** 创建时间(YYYY-MM-DD HH:mm:ss;后端返毫秒时间戳,适配层归一) */
|
||||||
|
createTime: string;
|
||||||
|
/** 我的角色:owner 负责人 / collaborator 协办人;双重身份只返 owner */
|
||||||
|
myRole: 'owner' | 'collaborator';
|
||||||
|
/** 父任务 ID(字符串),一级任务为 null */
|
||||||
|
parentTaskId: string | null;
|
||||||
|
/** 是否终态;本接口只返非终态任务,正常恒为 false */
|
||||||
|
terminal: boolean;
|
||||||
|
/** 当前状态是否允许编辑任务 */
|
||||||
|
allowEdit: boolean;
|
||||||
|
/** 当前登录用户可执行的生命周期动作(与任务详情同口径;auto_start 不返回),无动作为 [] */
|
||||||
|
availableActions: LifecycleAction<ProjectTaskActionCode>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「团队负载」分布子项(kind != project 时 projectId / projectName 为 null) */
|
||||||
|
interface TeamLoadDistributionItem {
|
||||||
|
projectId: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
/** project 项目任务 / personal 个人事项 / other 无法归类的残留 */
|
||||||
|
kind: 'project' | 'personal' | 'other';
|
||||||
|
/** 未完成任务数(含待开始/已暂停) */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「团队负载」成员(members[0] 恒为当前用户) */
|
||||||
|
interface TeamLoadMember {
|
||||||
|
/** 用户 ID(字符串) */
|
||||||
|
userId: string;
|
||||||
|
userNickname: string;
|
||||||
|
/** 未完成任务按归属分布,无任务为 [] */
|
||||||
|
items: TeamLoadDistributionItem[];
|
||||||
|
/** 临期:今天 ≤ 计划结束 ≤ 今天+3 天,且未完成(与逾期互斥) */
|
||||||
|
dueSoonCount: number;
|
||||||
|
/** 逾期:计划结束 < 今天,且未完成 */
|
||||||
|
overdueCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「团队负载」响应(GET /project/project/me/team-load,团队 = 自己 + 管理链路直接下级) */
|
||||||
|
interface TeamLoadResult {
|
||||||
|
members: TeamLoadMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台工时分布子项(kind != project 时 projectId / projectName 为 null;hours=0 的行后端不输出) */
|
||||||
|
interface WorklogDistributionItem {
|
||||||
|
projectId: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
kind: 'project' | 'personal' | 'other';
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「我的工时周聚合」响应(GET /project/project/me/worklog-week) */
|
||||||
|
interface MyWorklogWeekResult {
|
||||||
|
/** 归一后的周一日期 YYYY-MM-DD */
|
||||||
|
weekStart: string;
|
||||||
|
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
|
||||||
|
dailyHours: number[];
|
||||||
|
/** 本周工时按归属分布,hours 降序 */
|
||||||
|
distribution: WorklogDistributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「团队工时周聚合」成员(members[0] 恒为当前用户;该周未填报成员 items 为 []) */
|
||||||
|
interface TeamWorklogWeekMember {
|
||||||
|
userId: string;
|
||||||
|
userNickname: string;
|
||||||
|
items: WorklogDistributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台「团队工时周聚合」响应(GET /project/project/me/team-worklog-week;周标准工时后端不返回,前端落常量 35) */
|
||||||
|
interface TeamWorklogWeekResult {
|
||||||
|
weekStart: string;
|
||||||
|
members: TeamWorklogWeekMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台工时周聚合查询入参(weekStart 传任意日期,后端归一到所在周周一) */
|
||||||
|
interface WorklogWeekParams {
|
||||||
|
weekStart: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 创建执行入参(含 ownerId + assigneeUserIds) */
|
/** 创建执行入参(含 ownerId + assigneeUserIds) */
|
||||||
interface CreateProjectExecutionParams {
|
interface CreateProjectExecutionParams {
|
||||||
executionName: string;
|
executionName: string;
|
||||||
@@ -265,6 +519,8 @@ declare namespace Api {
|
|||||||
projectRequirementId: string | null;
|
projectRequirementId: string | null;
|
||||||
plannedStartDate: string | null;
|
plannedStartDate: string | null;
|
||||||
plannedEndDate: string | null;
|
plannedEndDate: string | null;
|
||||||
|
/** 优先级字典 value,必填,String "0"~"3" */
|
||||||
|
priority: string;
|
||||||
executionDesc: string | null;
|
executionDesc: string | null;
|
||||||
assigneeUserIds?: string[];
|
assigneeUserIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -279,6 +535,8 @@ declare namespace Api {
|
|||||||
projectRequirementId: string | null;
|
projectRequirementId: string | null;
|
||||||
plannedStartDate: string | null;
|
plannedStartDate: string | null;
|
||||||
plannedEndDate: string | null;
|
plannedEndDate: string | null;
|
||||||
|
/** 优先级字典 value,必填,String "0"~"3" */
|
||||||
|
priority: string;
|
||||||
executionDesc: string | null;
|
executionDesc: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +564,8 @@ declare namespace Api {
|
|||||||
parentTaskId: string;
|
parentTaskId: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
|
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||||
|
priority: string;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -330,6 +590,8 @@ declare namespace Api {
|
|||||||
keyword: string;
|
keyword: string;
|
||||||
parentTaskId: string;
|
parentTaskId: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||||
|
priority: string;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -347,13 +609,93 @@ declare namespace Api {
|
|||||||
items: ProjectTaskBoardColumn[];
|
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(不传也走 mine);all 必须有 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 {
|
interface SaveProjectTaskParams {
|
||||||
parentTaskId: string | null;
|
parentTaskId: string | null;
|
||||||
taskTitle: string;
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
progressRate?: number;
|
progressRate?: number;
|
||||||
plannedStartDate: string | null;
|
plannedStartDate: string | null;
|
||||||
plannedEndDate: string | null;
|
plannedEndDate: string | null;
|
||||||
|
/** 优先级字典 value,必填,String "0"~"3" */
|
||||||
|
priority: string;
|
||||||
taskDesc: string | null;
|
taskDesc: string | null;
|
||||||
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行协办人且不能等于 ownerId */
|
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行协办人且不能等于 ownerId */
|
||||||
assigneeUserIds?: string[];
|
assigneeUserIds?: string[];
|
||||||
@@ -380,6 +722,10 @@ declare namespace Api {
|
|||||||
durationHours: number;
|
durationHours: number;
|
||||||
/** 本次填报进度(0~100,scale=2) */
|
/** 本次填报进度(0~100,scale=2) */
|
||||||
progressRate: number;
|
progressRate: number;
|
||||||
|
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||||
|
difficulty: string;
|
||||||
|
/** 后端预留字段,目前始终为 null,前端按 difficulty + 字典 cache 自译 */
|
||||||
|
difficultyName?: string | null;
|
||||||
workContent: string | null;
|
workContent: string | null;
|
||||||
attachments?: AttachmentItem[] | null;
|
attachments?: AttachmentItem[] | null;
|
||||||
createTime: string;
|
createTime: string;
|
||||||
@@ -391,6 +737,8 @@ declare namespace Api {
|
|||||||
userId: string;
|
userId: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
/** 完成难度筛选,等值匹配;不传 = 全部 */
|
||||||
|
difficulty: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -403,6 +751,8 @@ declare namespace Api {
|
|||||||
durationHours: number;
|
durationHours: number;
|
||||||
/** 本次填报进度(0~100,scale=2,必填) */
|
/** 本次填报进度(0~100,scale=2,必填) */
|
||||||
progressRate: number;
|
progressRate: number;
|
||||||
|
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||||
|
difficulty: string;
|
||||||
workContent?: string | null;
|
workContent?: string | null;
|
||||||
/** 编辑语义:null 保留原值 / [] 清空 / [...] 替换 */
|
/** 编辑语义:null 保留原值 / [] 清空 / [...] 替换 */
|
||||||
attachments?: AttachmentItem[] | null;
|
attachments?: AttachmentItem[] | null;
|
||||||
@@ -437,10 +787,29 @@ declare namespace Api {
|
|||||||
list: T[];
|
list: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回) */
|
||||||
|
interface OverviewStatusItem {
|
||||||
|
statusCode: string;
|
||||||
|
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
|
||||||
|
statusName: string;
|
||||||
|
count: number;
|
||||||
|
sort: number;
|
||||||
|
/** 是否终态(状态机 terminal_flag);不能用于"全部"排除或左栏分区(completed 也可能是终态) */
|
||||||
|
terminal: boolean;
|
||||||
|
/** 是否计入"全部";当前口径无排除项恒为 true,将来恢复排除项由该字段表达 */
|
||||||
|
includeInAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** 项目入口页概览统计 */
|
/** 项目入口页概览统计 */
|
||||||
interface ProjectOverviewSummary {
|
interface ProjectOverviewSummary {
|
||||||
/** 项目状态数量映射,key 为后端状态编码 */
|
/** 项目状态数量映射,key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
|
||||||
statusCounts: Record<string, number>;
|
statusCounts: Record<string, number>;
|
||||||
|
/** "全部"口径总数 = items 各状态 count 之和(作废/归档计入) */
|
||||||
|
total: number;
|
||||||
|
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
|
||||||
|
items: OverviewStatusItem[];
|
||||||
|
/** 游离项目计数 = 所有未挂产品的项目(不按状态过滤),左栏游离入口据此显隐 */
|
||||||
|
orphanCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -543,11 +912,75 @@ declare namespace Api {
|
|||||||
projectType: string;
|
projectType: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
managerUserId: string;
|
managerUserId: string;
|
||||||
statusCode: ProjectStatusCode;
|
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||||
|
statusCode: string;
|
||||||
|
/** 多值状态筛选(存在时后端优先于单值 statusCode);分组页"展开剩余"按"全部"口径传 items 派生的全量编码 */
|
||||||
|
statusCodes: string[];
|
||||||
|
/** 仅查游离项目(productId 为空);与 productId 互斥,分组页展开游离组剩余时用 */
|
||||||
|
orphanOnly: boolean;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目列表"按产品分组"查询入参(GET /project/project/group-page)。
|
||||||
|
*
|
||||||
|
* - pageNo / pageSize 为**产品组维度**分页(一页 M 个产品组),不是项目行分页。
|
||||||
|
* - statusCode 不传 = "全部"视图(后端从状态机推导;2026-06-11 口径变更后无排除项,作废/归档计入)。
|
||||||
|
* - orphanOnly = true 仅返回游离组(productId 为空的项目);不可与 productId 同传。
|
||||||
|
* - topN:每组返回项目条数上限(后端默认 5,范围 1~50),超出由页面展开拉取。
|
||||||
|
*/
|
||||||
|
type ProjectGroupSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
productId: string;
|
||||||
|
projectType: string;
|
||||||
|
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||||
|
statusCode: string;
|
||||||
|
orphanOnly: boolean;
|
||||||
|
topN: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 按产品聚合的项目分组 */
|
||||||
|
interface ProjectGroup {
|
||||||
|
/** 产品 ID;游离组为 null */
|
||||||
|
productId: string | null;
|
||||||
|
/** 产品名称;游离组固定为"游离项目" */
|
||||||
|
productName: string;
|
||||||
|
/** 产品编码;游离组为 null */
|
||||||
|
productCode: string | null;
|
||||||
|
/** 产品方向字典值;游离组为空串 */
|
||||||
|
directionCode: string;
|
||||||
|
/** 产品经理用户 ID */
|
||||||
|
managerUserId: string | null;
|
||||||
|
/** 产品经理昵称(后端回填;游离组为 null,前端 managerLabelMap 兜底) */
|
||||||
|
managerUserNickname: string | null;
|
||||||
|
/** 当前筛选口径下组内项目总数 */
|
||||||
|
projectTotal: number;
|
||||||
|
/** 组内项目前 topN 条,按最近更新倒序;剩余由页面按 productId/orphanOnly + statusCodes 走 page 接口展开拉取 */
|
||||||
|
projects: Project[];
|
||||||
|
/** 组内按项目类型字典 value 的计数(现状按"全部口径"统计;已提需求改为跟随 statusCode 与 projectTotal 同口径,后端落地后更新本注释) */
|
||||||
|
typeCounts: Record<string, number>;
|
||||||
|
/** 是否已有主线项目(口径=存在非已取消 cancelled 的主线,已归档/完成也算占坑);前端直接消费、不用 typeCounts 推导 */
|
||||||
|
hasBaseline: boolean;
|
||||||
|
/** 是否游离组(未挂产品) */
|
||||||
|
orphan: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产品分组分页结果 */
|
||||||
|
interface ProjectGroupPageResult {
|
||||||
|
/** 当前筛选口径下产品组总数(分页 total,含游离组) */
|
||||||
|
total: number;
|
||||||
|
/** 当前筛选口径下项目总数(标题 meta 用) */
|
||||||
|
projectTotal: number;
|
||||||
|
/** 当前筛选口径下可见产品跨方向数(≥2 时前端渲染方向层) */
|
||||||
|
directionCount: number;
|
||||||
|
/** 当前筛选口径下游离项目数(标题/分页用);左栏常驻游离计数改用 overview-summary 的 orphanCount 全口径 */
|
||||||
|
orphanTotal: number;
|
||||||
|
list: ProjectGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 创建/保存项目参数 */
|
/** 创建/保存项目参数 */
|
||||||
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
|
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
|
||||||
projectCode: string | null;
|
projectCode: string | null;
|
||||||
@@ -597,6 +1030,24 @@ declare namespace Api {
|
|||||||
reason: 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 {
|
interface CreateProjectMemberParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -655,14 +1106,28 @@ declare namespace Api {
|
|||||||
// ========== 项目需求相关类型定义 ==========
|
// ========== 项目需求相关类型定义 ==========
|
||||||
/** 项目需求状态编码 */
|
/** 项目需求状态编码 */
|
||||||
type ProjectRequirementStatusCode =
|
type ProjectRequirementStatusCode =
|
||||||
| 'pending_confirm'
|
| 'pending_claim'
|
||||||
| 'pending_review'
|
| 'pending_review'
|
||||||
|
| 'reviewed'
|
||||||
|
| 'review_rejected'
|
||||||
| 'implementing'
|
| 'implementing'
|
||||||
| 'accepted'
|
| 'accepted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
| 'rejected'
|
| 'rejected'
|
||||||
| 'cancelled';
|
| '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 ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
|
||||||
|
|
||||||
@@ -719,14 +1184,14 @@ declare namespace Api {
|
|||||||
expectedTime?: string | null;
|
expectedTime?: string | null;
|
||||||
/** 排序值 */
|
/** 排序值 */
|
||||||
sort: number;
|
sort: number;
|
||||||
|
/** 项目需求进度(BigDecimal,0.00 ~ 1.00;HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
|
||||||
|
progressRate: number;
|
||||||
/** 创建时间 */
|
/** 创建时间 */
|
||||||
createTime: string;
|
createTime: string;
|
||||||
/** 更新时间 */
|
/** 更新时间 */
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
/** 子需求列表 */
|
/** 子需求列表 */
|
||||||
children?: ProjectRequirement[];
|
children?: ProjectRequirement[];
|
||||||
/** 是否终态 */
|
|
||||||
terminal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectRequirementModule {
|
interface ProjectRequirementModule {
|
||||||
@@ -759,25 +1224,18 @@ declare namespace Api {
|
|||||||
initialFlag: boolean;
|
initialFlag: boolean;
|
||||||
/** 是否终态 */
|
/** 是否终态 */
|
||||||
terminalFlag: boolean;
|
terminalFlag: boolean;
|
||||||
|
/** 是否允许编辑 */
|
||||||
|
allowEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectRequirementLifecycleAction {
|
interface ProjectRequirementLifecycleAction {
|
||||||
actionCode: string;
|
actionCode: ProjectRequirementStatusActionCode;
|
||||||
actionName: string;
|
actionName: string;
|
||||||
toStatusCode: string;
|
toStatusCode: string;
|
||||||
toStatusName: string;
|
toStatusName: string;
|
||||||
needReason: boolean;
|
needReason: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectRequirementLifecycleInfo {
|
|
||||||
statusCode: ProjectRequirementStatusCode;
|
|
||||||
statusName?: string | null;
|
|
||||||
lastStatusReason?: string | null;
|
|
||||||
terminal: boolean;
|
|
||||||
allowEdit: boolean;
|
|
||||||
availableActions: ProjectRequirementLifecycleAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectRequirementBatchReqVO {
|
interface ProjectRequirementBatchReqVO {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
@@ -788,6 +1246,40 @@ declare namespace Api {
|
|||||||
transitions: ProjectRequirementLifecycleAction[];
|
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<
|
type ProjectRequirementSearchParams = CommonType.RecordNullable<
|
||||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
|
|||||||
296
src/typings/api/work-report.d.ts
vendored
Normal file
296
src/typings/api/work-report.d.ts
vendored
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
namespace WorkReport {
|
||||||
|
namespace Common {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportType = 'weekly' | 'monthly' | 'project';
|
||||||
|
type WorkReportStatusCode = 'draft' | 'pending_approval' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
interface WorkReportStatusDict {
|
||||||
|
statusCode: WorkReportStatusCode | string;
|
||||||
|
statusName: string;
|
||||||
|
sort: number;
|
||||||
|
initialFlag: boolean;
|
||||||
|
terminalFlag: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkReportApprovalRecord {
|
||||||
|
id: string;
|
||||||
|
statusLogId: string;
|
||||||
|
approvalRound: number;
|
||||||
|
conclusion: string;
|
||||||
|
opinion?: string | null;
|
||||||
|
auditorUserId: string;
|
||||||
|
auditorName: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalReportReviewItem {
|
||||||
|
id?: string;
|
||||||
|
itemNumber?: number | null;
|
||||||
|
itemTitle: string;
|
||||||
|
workHours?: number | null;
|
||||||
|
contentText?: string | null;
|
||||||
|
contentJson?: unknown;
|
||||||
|
reflectionText?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalReportPlanItem {
|
||||||
|
id?: string;
|
||||||
|
itemNumber?: number | null;
|
||||||
|
itemTitle: string;
|
||||||
|
targetText?: string | null;
|
||||||
|
targetJson?: unknown;
|
||||||
|
supportNeed?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkReportBaseSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
statusCode: WorkReportStatusCode | string;
|
||||||
|
periodStartDate: string[];
|
||||||
|
submitTime: string[];
|
||||||
|
supervisorName: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ContentExportParams<TSearch> = Partial<TSearch> & {
|
||||||
|
exportAll?: boolean;
|
||||||
|
ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusActionParams {
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Weekly {
|
||||||
|
interface WeeklyReportTravelSegment {
|
||||||
|
id?: string;
|
||||||
|
sort?: number | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
travelDays?: number | null;
|
||||||
|
location?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyReport {
|
||||||
|
id: string;
|
||||||
|
reporterId: string;
|
||||||
|
reporterName: string;
|
||||||
|
reporterDeptName?: string | null;
|
||||||
|
reporterPostName?: string | null;
|
||||||
|
supervisorUserId: string;
|
||||||
|
supervisorName: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
statusCode: Common.WorkReportStatusCode | string;
|
||||||
|
statusName: string;
|
||||||
|
allowEdit: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
isBusinessTrip: boolean;
|
||||||
|
totalTravelDays?: number | string | null;
|
||||||
|
totalWorkHours?: number | string | null;
|
||||||
|
approvalComment?: string | null;
|
||||||
|
lastStatusReason?: string | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
approvalTime?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
updateTime?: string | null;
|
||||||
|
reviewItems: Common.PersonalReportReviewItem[];
|
||||||
|
planItems: Common.PersonalReportPlanItem[];
|
||||||
|
travelSegments: WeeklyReportTravelSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeeklyReportSearchParams = Common.WorkReportBaseSearchParams & {
|
||||||
|
isBusinessTrip?: boolean | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WeeklyReportSaveParams {
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
isBusinessTrip: boolean;
|
||||||
|
reviewItems: Common.PersonalReportReviewItem[];
|
||||||
|
planItems: Common.PersonalReportPlanItem[];
|
||||||
|
travelSegments: WeeklyReportTravelSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeeklyReportDefaultDraftParams = Pick<
|
||||||
|
WeeklyReportSaveParams,
|
||||||
|
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type WeeklyReportRefreshDraftParams = WeeklyReportSaveParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Monthly {
|
||||||
|
interface MonthlyReport {
|
||||||
|
id: string;
|
||||||
|
reporterId: string;
|
||||||
|
reporterName: string;
|
||||||
|
reporterDeptName?: string | null;
|
||||||
|
reporterPostName?: string | null;
|
||||||
|
supervisorUserId: string;
|
||||||
|
supervisorName: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
statusCode: Common.WorkReportStatusCode | string;
|
||||||
|
statusName: string;
|
||||||
|
allowEdit: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
totalWorkHours?: number | string | null;
|
||||||
|
approvalComment?: string | null;
|
||||||
|
lastStatusReason?: string | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
approvalTime?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
updateTime?: string | null;
|
||||||
|
reviewItems: Common.PersonalReportReviewItem[];
|
||||||
|
planItems: Common.PersonalReportPlanItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthlyReportSearchParams = Common.WorkReportBaseSearchParams;
|
||||||
|
|
||||||
|
interface MonthlyReportSaveParams {
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
reviewItems: Common.PersonalReportReviewItem[];
|
||||||
|
planItems: Common.PersonalReportPlanItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthlyReportDefaultDraftParams = Pick<
|
||||||
|
MonthlyReportSaveParams,
|
||||||
|
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type MonthlyReportRefreshDraftParams = MonthlyReportSaveParams;
|
||||||
|
|
||||||
|
interface MonthlyReportApproveParams extends Common.StatusActionParams {
|
||||||
|
meetingDate?: string | null;
|
||||||
|
strengthDesc?: string | null;
|
||||||
|
strengthExample?: string | null;
|
||||||
|
weaknessDesc?: string | null;
|
||||||
|
weaknessExample?: string | null;
|
||||||
|
improvementSuggestion?: string | null;
|
||||||
|
performanceResult?: string | null;
|
||||||
|
employeeSignName?: string | null;
|
||||||
|
employeeSignedDate?: string | null;
|
||||||
|
supervisorSignName?: string | null;
|
||||||
|
supervisorSignedDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyReportApprovalRecord extends Common.WorkReportApprovalRecord {
|
||||||
|
meetingDate?: string | null;
|
||||||
|
strengthDesc?: string | null;
|
||||||
|
strengthExample?: string | null;
|
||||||
|
weaknessDesc?: string | null;
|
||||||
|
weaknessExample?: string | null;
|
||||||
|
improvementSuggestion?: string | null;
|
||||||
|
performanceResult?: string | null;
|
||||||
|
employeeSignName?: string | null;
|
||||||
|
employeeSignedDate?: string | null;
|
||||||
|
supervisorSignName?: string | null;
|
||||||
|
supervisorSignedDate?: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Project {
|
||||||
|
interface WorkReportMemberSnapshot {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectReportItem {
|
||||||
|
id?: string;
|
||||||
|
itemTitle: string;
|
||||||
|
workHours?: number | null;
|
||||||
|
priorityCode?: string | null;
|
||||||
|
progressRate?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectReportOwnerProjectOption {
|
||||||
|
id: string;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectReport {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
projectOwnerId: string;
|
||||||
|
projectOwnerName: string;
|
||||||
|
technicalOwnerName?: string | null;
|
||||||
|
projectMemberSnapshot: WorkReportMemberSnapshot[];
|
||||||
|
supervisorUserId: string;
|
||||||
|
supervisorName: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
flag: number;
|
||||||
|
statusCode: Common.WorkReportStatusCode | string;
|
||||||
|
statusName: string;
|
||||||
|
allowEdit: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
projectStatusDesc?: string | null;
|
||||||
|
projectProgressPlan?: string | null;
|
||||||
|
projectKeyPoints?: string | null;
|
||||||
|
projectProblems?: string | null;
|
||||||
|
totalWorkHours?: number | string | null;
|
||||||
|
approvalComment?: string | null;
|
||||||
|
lastStatusReason?: string | null;
|
||||||
|
submitTime?: string | null;
|
||||||
|
approvalTime?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
updateTime?: string | null;
|
||||||
|
currentItems: ProjectReportItem[];
|
||||||
|
nextItems: ProjectReportItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectReportSearchParams = Common.WorkReportBaseSearchParams & {
|
||||||
|
projectId?: string | null;
|
||||||
|
flag?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProjectReportSaveParams {
|
||||||
|
projectId: string;
|
||||||
|
periodKey: string;
|
||||||
|
periodLabel: string;
|
||||||
|
periodStartDate: string;
|
||||||
|
periodEndDate: string;
|
||||||
|
flag: number;
|
||||||
|
projectStatusDesc?: string | null;
|
||||||
|
projectProgressPlan?: string | null;
|
||||||
|
projectKeyPoints?: string | null;
|
||||||
|
projectProblems?: string | null;
|
||||||
|
currentItems: ProjectReportItem[];
|
||||||
|
nextItems: ProjectReportItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectReportDefaultDraftParams = Pick<
|
||||||
|
ProjectReportSaveParams,
|
||||||
|
'periodKey' | 'periodLabel' | 'periodStartDate' | 'periodEndDate' | 'flag'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ProjectReportRefreshDraftParams = Omit<ProjectReportSaveParams, 'projectId'>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/typings/app.d.ts
vendored
41
src/typings/app.d.ts
vendored
@@ -504,45 +504,6 @@ declare namespace App {
|
|||||||
};
|
};
|
||||||
creativity: string;
|
creativity: string;
|
||||||
};
|
};
|
||||||
function: {
|
|
||||||
tab: {
|
|
||||||
tabOperate: {
|
|
||||||
title: string;
|
|
||||||
addTab: string;
|
|
||||||
addTabDesc: string;
|
|
||||||
closeTab: string;
|
|
||||||
closeCurrentTab: string;
|
|
||||||
closeAboutTab: string;
|
|
||||||
addMultiTab: string;
|
|
||||||
addMultiTabDesc1: string;
|
|
||||||
addMultiTabDesc2: string;
|
|
||||||
};
|
|
||||||
tabTitle: {
|
|
||||||
title: string;
|
|
||||||
changeTitle: string;
|
|
||||||
change: string;
|
|
||||||
resetTitle: string;
|
|
||||||
reset: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
multiTab: {
|
|
||||||
routeParam: string;
|
|
||||||
backTab: string;
|
|
||||||
};
|
|
||||||
toggleAuth: {
|
|
||||||
toggleAccount: string;
|
|
||||||
authHook: string;
|
|
||||||
superAdminVisible: string;
|
|
||||||
adminVisible: string;
|
|
||||||
adminOrUserVisible: string;
|
|
||||||
};
|
|
||||||
request: {
|
|
||||||
repeatedErrorOccurOnce: string;
|
|
||||||
repeatedError: string;
|
|
||||||
repeatedErrorMsg1: string;
|
|
||||||
repeatedErrorMsg2: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
system: {
|
system: {
|
||||||
common: {
|
common: {
|
||||||
status: {
|
status: {
|
||||||
@@ -866,6 +827,7 @@ declare namespace App {
|
|||||||
dictStatus: string;
|
dictStatus: string;
|
||||||
dictLabel: string;
|
dictLabel: string;
|
||||||
dictValue: string;
|
dictValue: string;
|
||||||
|
colorType: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
form: {
|
form: {
|
||||||
@@ -874,6 +836,7 @@ declare namespace App {
|
|||||||
dictStatus: string;
|
dictStatus: string;
|
||||||
dictLabel: string;
|
dictLabel: string;
|
||||||
dictValue: string;
|
dictValue: string;
|
||||||
|
colorType: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/typings/components.d.ts
vendored
11
src/typings/components.d.ts
vendored
@@ -9,6 +9,7 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||||
|
AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.vue')['default']
|
||||||
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||||
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
|
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
|
||||||
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
||||||
@@ -18,6 +19,7 @@ declare module 'vue' {
|
|||||||
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
||||||
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
||||||
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||||
|
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||||
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||||
@@ -100,10 +102,14 @@ declare module 'vue' {
|
|||||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||||
|
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||||
'IconEp:box': typeof import('~icons/ep/box')['default']
|
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||||
|
'IconEp:check': typeof import('~icons/ep/check')['default']
|
||||||
'IconEp:files': typeof import('~icons/ep/files')['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:infoFilled': typeof import('~icons/ep/info-filled')['default']
|
||||||
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
||||||
|
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||||
@@ -123,6 +129,8 @@ declare module 'vue' {
|
|||||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
||||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||||
|
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||||
|
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
||||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||||
IconLocalCast: typeof import('~icons/local/cast')['default']
|
IconLocalCast: typeof import('~icons/local/cast')['default']
|
||||||
@@ -144,6 +152,7 @@ declare module 'vue' {
|
|||||||
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||||
|
IconMdiDownload: typeof import('~icons/mdi/download')['default']
|
||||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||||
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
|
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
|
||||||
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
||||||
@@ -151,6 +160,7 @@ declare module 'vue' {
|
|||||||
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
|
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
|
||||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||||
|
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
|
||||||
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
||||||
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
||||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||||
@@ -175,6 +185,7 @@ declare module 'vue' {
|
|||||||
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
|
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
|
||||||
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
||||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
|
||||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/typings/elegant-router.d.ts
vendored
74
src/typings/elegant-router.d.ts
vendored
@@ -24,16 +24,6 @@ declare module "@elegant-router/types" {
|
|||||||
"403": "/403";
|
"403": "/403";
|
||||||
"404": "/404";
|
"404": "/404";
|
||||||
"500": "/500";
|
"500": "/500";
|
||||||
"function": "/function";
|
|
||||||
"function_hide-child": "/function/hide-child";
|
|
||||||
"function_hide-child_one": "/function/hide-child/one";
|
|
||||||
"function_hide-child_three": "/function/hide-child/three";
|
|
||||||
"function_hide-child_two": "/function/hide-child/two";
|
|
||||||
"function_multi-tab": "/function/multi-tab";
|
|
||||||
"function_request": "/function/request";
|
|
||||||
"function_super-page": "/function/super-page";
|
|
||||||
"function_tab": "/function/tab";
|
|
||||||
"function_toggle-auth": "/function/toggle-auth";
|
|
||||||
"iframe-page": "/iframe-page/:url";
|
"iframe-page": "/iframe-page/:url";
|
||||||
"infra": "/infra";
|
"infra": "/infra";
|
||||||
"infra_rd-code": "/infra/rd-code";
|
"infra_rd-code": "/infra/rd-code";
|
||||||
@@ -45,32 +35,15 @@ declare module "@elegant-router/types" {
|
|||||||
"metrics_worktime": "/metrics/worktime";
|
"metrics_worktime": "/metrics/worktime";
|
||||||
"personal-center": "/personal-center";
|
"personal-center": "/personal-center";
|
||||||
"personal-center_my-application": "/personal-center/my-application";
|
"personal-center_my-application": "/personal-center/my-application";
|
||||||
"personal-center_my-monthly": "/personal-center/my-monthly";
|
"personal-center_my-item": "/personal-center/my-item";
|
||||||
"personal-center_my-performance": "/personal-center/my-performance";
|
"personal-center_my-performance": "/personal-center/my-performance";
|
||||||
"personal-center_my-profile": "/personal-center/my-profile";
|
"personal-center_my-profile": "/personal-center/my-profile";
|
||||||
"personal-center_my-weekly": "/personal-center/my-weekly";
|
"personal-center_overtime-application": "/personal-center/overtime-application";
|
||||||
"personal-center_pending-approval": "/personal-center/pending-approval";
|
"personal-center_pending-approval": "/personal-center/pending-approval";
|
||||||
"plugin": "/plugin";
|
"personal-center_work-report": "/personal-center/work-report";
|
||||||
"plugin_barcode": "/plugin/barcode";
|
"personal-center_work-report_monthly": "/personal-center/work-report/monthly";
|
||||||
"plugin_charts": "/plugin/charts";
|
"personal-center_work-report_project": "/personal-center/work-report/project";
|
||||||
"plugin_charts_antv": "/plugin/charts/antv";
|
"personal-center_work-report_weekly": "/personal-center/work-report/weekly";
|
||||||
"plugin_charts_echarts": "/plugin/charts/echarts";
|
|
||||||
"plugin_charts_vchart": "/plugin/charts/vchart";
|
|
||||||
"plugin_copy": "/plugin/copy";
|
|
||||||
"plugin_excel": "/plugin/excel";
|
|
||||||
"plugin_gantt": "/plugin/gantt";
|
|
||||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
|
|
||||||
"plugin_gantt_vtable": "/plugin/gantt/vtable";
|
|
||||||
"plugin_icon": "/plugin/icon";
|
|
||||||
"plugin_map": "/plugin/map";
|
|
||||||
"plugin_pdf": "/plugin/pdf";
|
|
||||||
"plugin_pinyin": "/plugin/pinyin";
|
|
||||||
"plugin_print": "/plugin/print";
|
|
||||||
"plugin_swiper": "/plugin/swiper";
|
|
||||||
"plugin_tables": "/plugin/tables";
|
|
||||||
"plugin_tables_vtable": "/plugin/tables/vtable";
|
|
||||||
"plugin_typeit": "/plugin/typeit";
|
|
||||||
"plugin_video": "/plugin/video";
|
|
||||||
"product": "/product";
|
"product": "/product";
|
||||||
"product_dashboard": "/product/dashboard";
|
"product_dashboard": "/product/dashboard";
|
||||||
"product_list": "/product/list";
|
"product_list": "/product/list";
|
||||||
@@ -133,13 +106,11 @@ declare module "@elegant-router/types" {
|
|||||||
| "403"
|
| "403"
|
||||||
| "404"
|
| "404"
|
||||||
| "500"
|
| "500"
|
||||||
| "function"
|
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "infra"
|
| "infra"
|
||||||
| "login"
|
| "login"
|
||||||
| "metrics"
|
| "metrics"
|
||||||
| "personal-center"
|
| "personal-center"
|
||||||
| "plugin"
|
|
||||||
| "product"
|
| "product"
|
||||||
| "project"
|
| "project"
|
||||||
| "system"
|
| "system"
|
||||||
@@ -167,42 +138,21 @@ declare module "@elegant-router/types" {
|
|||||||
| "500"
|
| "500"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
| "function_hide-child_one"
|
|
||||||
| "function_hide-child_three"
|
|
||||||
| "function_hide-child_two"
|
|
||||||
| "function_multi-tab"
|
|
||||||
| "function_request"
|
|
||||||
| "function_super-page"
|
|
||||||
| "function_tab"
|
|
||||||
| "function_toggle-auth"
|
|
||||||
| "infra_rd-code"
|
| "infra_rd-code"
|
||||||
| "infra_state-machine"
|
| "infra_state-machine"
|
||||||
| "metrics_member-efficiency"
|
| "metrics_member-efficiency"
|
||||||
| "metrics_project-progress"
|
| "metrics_project-progress"
|
||||||
| "metrics_worktime"
|
| "metrics_worktime"
|
||||||
| "personal-center_my-application"
|
| "personal-center_my-application"
|
||||||
| "personal-center_my-monthly"
|
| "personal-center_my-item"
|
||||||
| "personal-center_my-performance"
|
| "personal-center_my-performance"
|
||||||
| "personal-center_my-profile"
|
| "personal-center_my-profile"
|
||||||
| "personal-center_my-weekly"
|
| "personal-center_overtime-application"
|
||||||
| "personal-center_pending-approval"
|
| "personal-center_pending-approval"
|
||||||
| "plugin_barcode"
|
| "personal-center_work-report"
|
||||||
| "plugin_charts_antv"
|
| "personal-center_work-report_monthly"
|
||||||
| "plugin_charts_echarts"
|
| "personal-center_work-report_project"
|
||||||
| "plugin_charts_vchart"
|
| "personal-center_work-report_weekly"
|
||||||
| "plugin_copy"
|
|
||||||
| "plugin_excel"
|
|
||||||
| "plugin_gantt_dhtmlx"
|
|
||||||
| "plugin_gantt_vtable"
|
|
||||||
| "plugin_icon"
|
|
||||||
| "plugin_map"
|
|
||||||
| "plugin_pdf"
|
|
||||||
| "plugin_pinyin"
|
|
||||||
| "plugin_print"
|
|
||||||
| "plugin_swiper"
|
|
||||||
| "plugin_tables_vtable"
|
|
||||||
| "plugin_typeit"
|
|
||||||
| "plugin_video"
|
|
||||||
| "product_dashboard"
|
| "product_dashboard"
|
||||||
| "product_list"
|
| "product_list"
|
||||||
| "product_requirement"
|
| "product_requirement"
|
||||||
|
|||||||
20
src/typings/package.d.ts
vendored
20
src/typings/package.d.ts
vendored
@@ -1,20 +0,0 @@
|
|||||||
/// <reference types="@amap/amap-jsapi-types" />
|
|
||||||
/// <reference types="bmapgl" />
|
|
||||||
|
|
||||||
declare namespace BMap {
|
|
||||||
class Map extends BMapGL.Map {}
|
|
||||||
class Point extends BMapGL.Point {}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare const TMap: any;
|
|
||||||
|
|
||||||
interface Window {
|
|
||||||
/**
|
|
||||||
* make baidu map request under https protocol
|
|
||||||
*
|
|
||||||
* - 0: http
|
|
||||||
* - 1: https
|
|
||||||
* - 2: https
|
|
||||||
*/
|
|
||||||
HOST_TYPE: '0' | '1' | '2';
|
|
||||||
}
|
|
||||||
20
src/utils/datetime.ts
Normal file
20
src/utils/datetime.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
/** 相对时间展示:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 7 天回退完整日期 */
|
||||||
|
export function formatRelativeTime(value: string | number) {
|
||||||
|
const time = dayjs(value);
|
||||||
|
if (!time.isValid()) return '';
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
const diffMinutes = now.diff(time, 'minute');
|
||||||
|
if (diffMinutes < 1) return '刚刚';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
||||||
|
|
||||||
|
const diffHours = now.diff(time, 'hour');
|
||||||
|
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||||
|
|
||||||
|
const diffDays = now.diff(time, 'day');
|
||||||
|
if (diffDays < 7) return `${diffDays} 天前`;
|
||||||
|
|
||||||
|
return time.format('YYYY-MM-DD HH:mm');
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import type { CSSProperties, Component } from 'vue';
|
||||||
import { getPaletteColorByNumber, mixColor } from '@sa/color';
|
|
||||||
import { loginModuleRecord } from '@/constants/app';
|
import { loginModuleRecord } from '@/constants/app';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
@@ -31,46 +30,791 @@ const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
|
|||||||
|
|
||||||
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
||||||
|
|
||||||
const bgThemeColor = computed(() =>
|
const currentYear = new Date().getFullYear();
|
||||||
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
|
|
||||||
);
|
|
||||||
|
|
||||||
const bgColor = computed(() => {
|
/** 登录页品牌色:取自公司 logo 的湛蓝,不跟随系统主题色(主题色偏紫,与企业蓝不符) */
|
||||||
const COLOR_WHITE = '#ffffff';
|
const LOGIN_BRAND = '#1e80df';
|
||||||
|
|
||||||
const ratio = themeStore.darkMode ? 0.5 : 0.2;
|
/** 鼠标视差:归一化指针位置,不同景深的层按系数反向位移 */
|
||||||
|
const pointer = reactive({ x: 0, y: 0 });
|
||||||
|
|
||||||
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
|
function onPointerMove(event: MouseEvent) {
|
||||||
});
|
pointer.x = (event.clientX / window.innerWidth - 0.5) * 2;
|
||||||
|
pointer.y = (event.clientY / window.innerHeight - 0.5) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerStyle(depth: number) {
|
||||||
|
return {
|
||||||
|
transform: `translate3d(${(-pointer.x * depth).toFixed(1)}px, ${(-pointer.y * depth).toFixed(1)}px, 0)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 协作分支:角色 → 颜色 → 汇入主干的路径(git 分支汇流的意象),曲线两端均水平相切,过渡柔和 */
|
||||||
|
const branches = [
|
||||||
|
{
|
||||||
|
key: 'demand',
|
||||||
|
label: '需求',
|
||||||
|
color: '#f59e0b',
|
||||||
|
y0: 195,
|
||||||
|
mergeX: 660,
|
||||||
|
path: 'M -80,195 C 320,195 440,470 660,470',
|
||||||
|
dur: '7.5s',
|
||||||
|
begin: '0s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'design',
|
||||||
|
label: '设计',
|
||||||
|
color: '#ec4899',
|
||||||
|
y0: 330,
|
||||||
|
mergeX: 780,
|
||||||
|
path: 'M -80,330 C 360,330 540,470 780,470',
|
||||||
|
dur: '6.5s',
|
||||||
|
begin: '1.2s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dev',
|
||||||
|
label: '开发',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
y0: 615,
|
||||||
|
mergeX: 880,
|
||||||
|
path: 'M -80,615 C 380,615 560,470 880,470',
|
||||||
|
dur: '7s',
|
||||||
|
begin: '2.1s'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
label: '测试',
|
||||||
|
color: '#22c55e',
|
||||||
|
y0: 745,
|
||||||
|
mergeX: 970,
|
||||||
|
path: 'M -80,745 C 420,745 620,470 970,470',
|
||||||
|
dur: '8s',
|
||||||
|
begin: '0.6s'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 分支汇入主干的节点位置 */
|
||||||
|
const mergePoints = [
|
||||||
|
{ x: 660, color: '#f59e0b' },
|
||||||
|
{ x: 780, color: '#ec4899' },
|
||||||
|
{ x: 880, color: '#0ea5e9' },
|
||||||
|
{ x: 970, color: '#22c55e' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 角色徽章在场景中的落位(跟随分支起始段) */
|
||||||
|
const roleChips: { label: string; color: string; style: CSSProperties }[] = [
|
||||||
|
{ label: '需求', color: '#f59e0b', style: { left: '5%', top: '20%', '--float-d': '0s' } },
|
||||||
|
{ label: '设计', color: '#ec4899', style: { left: '11%', top: '35%', '--float-d': '0.8s' } },
|
||||||
|
{ label: '开发', color: '#0ea5e9', style: { left: '8%', top: '66%', '--float-d': '1.6s' } },
|
||||||
|
{ label: '测试', color: '#22c55e', style: { left: '13%', top: '80%', '--float-d': '2.4s' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电能质量波形(公司主营:电能质量监测)
|
||||||
|
*
|
||||||
|
* 主干汇流完成后,尾段"输出"为基波 + 谐波叠加的正弦波组,寓意协作成果守护电能质量。
|
||||||
|
* 用二次贝塞尔 Q/T 拼接出周期波形,CSS 平移一个整周期实现无缝流动。
|
||||||
|
*/
|
||||||
|
interface WaveShape {
|
||||||
|
/** 波形中线 y */
|
||||||
|
mid: number;
|
||||||
|
/** 振幅 */
|
||||||
|
amp: number;
|
||||||
|
/** 半周期(x 方向) */
|
||||||
|
half: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWavePath(shape: WaveShape, from: number, to: number) {
|
||||||
|
const { mid, amp, half } = shape;
|
||||||
|
let d = `M ${from} ${mid} Q ${from + half / 2} ${mid - amp} ${from + half} ${mid}`;
|
||||||
|
for (let x = from + 2 * half; x <= to; x += half) {
|
||||||
|
d += ` T ${x} ${mid}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waves = [
|
||||||
|
// 基波:主题色,振幅最大
|
||||||
|
{ key: 'fundamental', mid: 470, amp: 26, half: 110, color: 'var(--brand)', width: 2, opacity: 0.5, dur: '7s' },
|
||||||
|
// 高次谐波:短周期小振幅
|
||||||
|
{ key: 'harmonic', mid: 470, amp: 10, half: 55, color: '#0ea5e9', width: 1.5, opacity: 0.45, dur: '4.5s' },
|
||||||
|
// 低频包络:慢速衬底
|
||||||
|
{ key: 'flux', mid: 474, amp: 40, half: 220, color: '#60a5fa', width: 2, opacity: 0.22, dur: '14s' }
|
||||||
|
].map(wave => ({
|
||||||
|
...wave,
|
||||||
|
path: buildWavePath(wave, 900 - wave.half * 2, 2000 + wave.half * 2),
|
||||||
|
shift: `${-2 * wave.half}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
|
||||||
|
const towers = [
|
||||||
|
{ x: 150, s: 1 },
|
||||||
|
{ x: 540, s: 0.85 },
|
||||||
|
{ x: 1280, s: 0.7 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */
|
||||||
|
const powerLines = [
|
||||||
|
{ path: 'M -60,762 Q 30,800 92,750' },
|
||||||
|
{ path: 'M 208,750 Q 350,819 491,768' },
|
||||||
|
{ path: 'M 589,768 Q 914,867 1239,786' },
|
||||||
|
{ path: 'M 1321,786 Q 1430,824 1520,804' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 导线上滑过的电流光点 */
|
||||||
|
const lineSparks = [
|
||||||
|
{ key: 'spark-1', path: 'M 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' },
|
||||||
|
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 风机(新能源应用场景),dur 为叶轮旋转周期 */
|
||||||
|
const turbines = [
|
||||||
|
{ key: 'turbine-1', x: 715, s: 0.9, dur: '9s' },
|
||||||
|
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
|
<div class="login-scene" :style="{ '--brand': LOGIN_BRAND }" @mousemove="onPointerMove">
|
||||||
<WaveBg :theme-color="bgThemeColor" />
|
<!-- 远景:浮尘微粒 -->
|
||||||
<ElCard class="relative z-4 w-auto rd-12px">
|
<div class="scene-motes" :style="layerStyle(6)"></div>
|
||||||
<div class="w-400px lt-sm:w-300px">
|
|
||||||
<header class="flex-y-center justify-between">
|
<!-- 中景:协作汇流图(需求/设计/开发/测试 → 主干 → 登录入口) -->
|
||||||
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
|
<div class="scene-graph" :style="layerStyle(14)">
|
||||||
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
|
<svg class="scene-graph__svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||||
<div class="i-flex-col">
|
<defs>
|
||||||
<ThemeSchemaSwitch
|
<linearGradient id="trunk-grad" x1="0" y1="0" x2="1" y2="0">
|
||||||
:theme-schema="themeStore.themeScheme"
|
<!-- presentation attribute 不解析 CSS var,必须用 style -->
|
||||||
:show-tooltip="false"
|
<stop offset="0" style="stop-color: var(--brand); stop-opacity: 0" />
|
||||||
class="text-20px lt-sm:text-18px"
|
<stop offset="0.45" style="stop-color: var(--brand); stop-opacity: 0.85" />
|
||||||
@switch="themeStore.toggleThemeScheme"
|
<stop offset="1" style="stop-color: #0ea5e9; stop-opacity: 0.9" />
|
||||||
|
</linearGradient>
|
||||||
|
<!-- 每条分支一个渐变:起点透明、临近汇入处渐显,模拟光流自然汇聚 -->
|
||||||
|
<linearGradient
|
||||||
|
v-for="branch in branches"
|
||||||
|
:id="`branch-grad-${branch.key}`"
|
||||||
|
:key="`grad-${branch.key}`"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
:x1="-80"
|
||||||
|
:y1="branch.y0"
|
||||||
|
:x2="branch.mergeX"
|
||||||
|
:y2="470"
|
||||||
|
>
|
||||||
|
<stop offset="0" :stop-color="branch.color" stop-opacity="0" />
|
||||||
|
<stop offset="0.45" :stop-color="branch.color" stop-opacity="0.2" />
|
||||||
|
<stop offset="1" :stop-color="branch.color" stop-opacity="0.65" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="trunk-glow" x="-30%" y="-300%" width="160%" height="700%">
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<!-- 波形渐显遮罩:汇流完成后波形才"长出来",遮罩静止、波形在其下平移 -->
|
||||||
|
<linearGradient id="wave-fade" gradientUnits="userSpaceOnUse" x1="920" y1="0" x2="1980" y2="0">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity="0" />
|
||||||
|
<stop offset="0.3" stop-color="#fff" stop-opacity="0.9" />
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity="1" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="wave-mask">
|
||||||
|
<rect x="920" y="330" width="1100" height="300" fill="url(#wave-fade)" />
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 主干 -->
|
||||||
|
<path class="trunk" d="M -60,470 L 1520,470" stroke="url(#trunk-grad)" filter="url(#trunk-glow)" />
|
||||||
|
|
||||||
|
<!-- 四条角色分支 -->
|
||||||
|
<path
|
||||||
|
v-for="(branch, index) in branches"
|
||||||
|
:key="branch.key"
|
||||||
|
class="branch"
|
||||||
|
:d="branch.path"
|
||||||
|
:stroke="`url(#branch-grad-${branch.key})`"
|
||||||
|
:style="{ '--breathe-d': `${index * 1.4}s` }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 分支上行进的光点 -->
|
||||||
|
<circle
|
||||||
|
v-for="branch in branches"
|
||||||
|
:key="`dot-${branch.key}`"
|
||||||
|
r="3.5"
|
||||||
|
:fill="branch.color"
|
||||||
|
class="travel-dot"
|
||||||
|
:style="{ color: branch.color }"
|
||||||
|
>
|
||||||
|
<animateMotion :dur="branch.dur" :begin="branch.begin" repeatCount="indefinite" :path="branch.path" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 主干上行进的光点 -->
|
||||||
|
<circle r="3" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
|
||||||
|
<animateMotion dur="4.5s" begin="0.5s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
|
||||||
|
</circle>
|
||||||
|
<circle r="2.5" fill="#5db1f5" class="travel-dot" style="color: #5db1f5">
|
||||||
|
<animateMotion dur="5.5s" begin="2.6s" repeatCount="indefinite" path="M 660,470 L 1520,470" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 电能质量波形:基波 + 谐波 + 低频包络,沿主干尾段流出 -->
|
||||||
|
<g mask="url(#wave-mask)">
|
||||||
|
<path
|
||||||
|
v-for="wave in waves"
|
||||||
|
:key="wave.key"
|
||||||
|
class="wave"
|
||||||
|
:d="wave.path"
|
||||||
|
:stroke-width="wave.width"
|
||||||
|
:style="{
|
||||||
|
stroke: wave.color,
|
||||||
|
opacity: wave.opacity,
|
||||||
|
'--wave-shift': wave.shift,
|
||||||
|
'--wave-dur': wave.dur
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 电力场景剪影:输电铁塔 + 悬垂导线 + 风机 -->
|
||||||
|
<g class="industry">
|
||||||
|
<!-- 输电铁塔 -->
|
||||||
|
<g
|
||||||
|
v-for="tower in towers"
|
||||||
|
:key="`tower-${tower.x}`"
|
||||||
|
:transform="`translate(${tower.x}, 870) scale(${tower.s})`"
|
||||||
|
>
|
||||||
|
<path d="M -32 0 L -10 -150 L 0 -178 L 10 -150 L 32 0" />
|
||||||
|
<path
|
||||||
|
d="M -28 -25 L 28 -45 M 28 -25 L -28 -45 M -24 -70 L 24 -88 M 24 -70 L -24 -88 M -19 -112 L 19 -126 M 19 -112 L -19 -126"
|
||||||
|
/>
|
||||||
|
<path d="M -58 -120 L 58 -120 M -46 -150 L 46 -150" />
|
||||||
|
<path d="M -58 -120 L -58 -110 M 58 -120 L 58 -110 M -46 -150 L -46 -140 M 46 -150 L 46 -140" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 塔间导线 -->
|
||||||
|
<path v-for="line in powerLines" :key="line.path" :d="line.path" />
|
||||||
|
|
||||||
|
<!-- 风机 -->
|
||||||
|
<g
|
||||||
|
v-for="turbine in turbines"
|
||||||
|
:key="turbine.key"
|
||||||
|
:transform="`translate(${turbine.x}, 870) scale(${turbine.s})`"
|
||||||
|
>
|
||||||
|
<path d="M -3 0 L 0 -120 M 3 0 L 0 -120" />
|
||||||
|
<g transform="translate(0, -120)">
|
||||||
|
<g>
|
||||||
|
<path d="M 0 0 L 0 -52" />
|
||||||
|
<path d="M 0 0 L 0 -52" transform="rotate(120)" />
|
||||||
|
<path d="M 0 0 L 0 -52" transform="rotate(240)" />
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
from="0 0 0"
|
||||||
|
to="360 0 0"
|
||||||
|
:dur="turbine.dur"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<circle cx="0" cy="-120" r="3" class="industry__hub" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 导线上的电流光点 -->
|
||||||
|
<circle v-for="spark in lineSparks" :key="spark.key" r="2.5" class="travel-dot industry__spark">
|
||||||
|
<animateMotion :dur="spark.dur" :begin="spark.begin" repeatCount="indefinite" :path="spark.path" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 汇入节点:脉冲 -->
|
||||||
|
<g v-for="point in mergePoints" :key="`merge-${point.x}`">
|
||||||
|
<circle :cx="point.x" cy="470" r="5" :fill="point.color" />
|
||||||
|
<circle :cx="point.x" cy="470" r="5" :stroke="point.color" class="merge-pulse" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 角色徽章 -->
|
||||||
|
<span v-for="chip in roleChips" :key="chip.label" class="role-chip" :style="chip.style">
|
||||||
|
<i class="role-chip__dot" :style="{ backgroundColor: chip.color }"></i>
|
||||||
|
{{ chip.label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部:品牌 -->
|
||||||
|
<header class="scene-header reveal" style="--d: 0s">
|
||||||
|
<SystemLogo class="text-36px" />
|
||||||
|
<span class="scene-header__name">{{ $t('system.title') }}</span>
|
||||||
</header>
|
</header>
|
||||||
<main class="pt-15px">
|
|
||||||
<div class="pt-15px">
|
<!-- 主文案 -->
|
||||||
|
<div class="scene-hero" :style="layerStyle(10)">
|
||||||
|
<p class="scene-hero__eyebrow reveal" style="--d: 0.15s">BUILD TOGETHER · GUARD POWER QUALITY</p>
|
||||||
|
<h1 class="scene-hero__slogan reveal" style="--d: 0.25s">
|
||||||
|
独行快
|
||||||
|
<span class="scene-hero__comma">,</span>
|
||||||
|
<br />
|
||||||
|
众行
|
||||||
|
<em>远</em>
|
||||||
|
</h1>
|
||||||
|
<p class="scene-hero__sub reveal" style="--d: 0.4s">每一次提交,都让电能质量的守护更进一步</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录卡片 -->
|
||||||
|
<div class="login-card">
|
||||||
|
<header class="login-card__header reveal" style="--d: 0.3s">
|
||||||
|
<SystemLogo class="login-card__logo text-52px" />
|
||||||
|
<h2 class="login-card__title">{{ $t('system.title') }}</h2>
|
||||||
|
<p class="login-card__subtitle">欢迎回来,开始今天的协作</p>
|
||||||
|
</header>
|
||||||
|
<main class="reveal" style="--d: 0.45s">
|
||||||
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
|
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
|
||||||
<component :is="activeModule.component" />
|
<component :is="activeModule.component" />
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ElCard>
|
|
||||||
|
<footer class="scene-footer reveal" style="--d: 0.6s">
|
||||||
|
© {{ currentYear }} 南京灿能电力自动化股份有限公司 · {{ $t('system.title') }}
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped lang="scss">
|
||||||
|
.login-scene {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-right: 9vw;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(90% 70% at 80% 42%, color-mix(in srgb, var(--brand) 10%, transparent) 0%, transparent 60%),
|
||||||
|
radial-gradient(80% 60% at 6% 92%, rgb(56 189 248 / 10%) 0%, transparent 60%),
|
||||||
|
radial-gradient(70% 50% at 18% 8%, rgb(14 165 233 / 6%) 0%, transparent 55%),
|
||||||
|
linear-gradient(160deg, #f5f9ff 0%, #ecf2fb 50%, #fafcff 100%);
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 远景浮尘微粒 ---------- */
|
||||||
|
.scene-motes {
|
||||||
|
position: absolute;
|
||||||
|
inset: -40px;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(2px 2px at 12% 22%, rgb(30 128 223 / 30%) 50%, transparent 51%),
|
||||||
|
radial-gradient(1.5px 1.5px at 28% 68%, rgb(23 44 84 / 16%) 50%, transparent 51%),
|
||||||
|
radial-gradient(2.5px 2.5px at 44% 12%, rgb(30 128 223 / 22%) 50%, transparent 51%),
|
||||||
|
radial-gradient(1.5px 1.5px at 58% 44%, rgb(23 44 84 / 12%) 50%, transparent 51%),
|
||||||
|
radial-gradient(2px 2px at 72% 78%, rgb(56 189 248 / 25%) 50%, transparent 51%),
|
||||||
|
radial-gradient(1.5px 1.5px at 86% 28%, rgb(23 44 84 / 14%) 50%, transparent 51%),
|
||||||
|
radial-gradient(2px 2px at 94% 62%, rgb(30 128 223 / 20%) 50%, transparent 51%),
|
||||||
|
radial-gradient(1.5px 1.5px at 6% 86%, rgb(56 189 248 / 18%) 50%, transparent 51%);
|
||||||
|
background-size: 520px 520px;
|
||||||
|
background-repeat: repeat;
|
||||||
|
animation: motes-breathe 6s ease-in-out infinite alternate;
|
||||||
|
transition: transform 0.25s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes motes-breathe {
|
||||||
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 协作汇流图 ---------- */
|
||||||
|
.scene-graph {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transition: transform 0.25s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-graph__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trunk {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
animation: branch-breathe 6s ease-in-out infinite alternate;
|
||||||
|
animation-delay: var(--breathe-d, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes branch-breathe {
|
||||||
|
from {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-dot {
|
||||||
|
filter: drop-shadow(0 0 6px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave {
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
animation: wave-drift var(--wave-dur) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave-drift {
|
||||||
|
to {
|
||||||
|
transform: translateX(var(--wave-shift));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 电力场景剪影 */
|
||||||
|
.industry {
|
||||||
|
fill: none;
|
||||||
|
stroke: #424a8c;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.industry__hub {
|
||||||
|
fill: #424a8c;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.industry__spark {
|
||||||
|
fill: var(--brand);
|
||||||
|
color: var(--brand);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-pulse {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: merge-pulse 2.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes merge-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(3.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 角色徽章 */
|
||||||
|
.role-chip {
|
||||||
|
position: absolute;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: 1px solid rgb(30 35 80 / 10%);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: rgb(30 35 80 / 72%);
|
||||||
|
background: rgb(255 255 255 / 65%);
|
||||||
|
box-shadow: 0 6px 18px -8px rgb(23 92 171 / 22%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: chip-float 5.5s ease-in-out infinite alternate;
|
||||||
|
animation-delay: var(--float-d, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 8px 1px currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chip-float {
|
||||||
|
from {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 品牌与文案 ---------- */
|
||||||
|
.scene-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
left: 56px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #232850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-header__name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-hero {
|
||||||
|
position: absolute;
|
||||||
|
top: 24%;
|
||||||
|
left: 6.5%;
|
||||||
|
z-index: 2;
|
||||||
|
color: #1b2050;
|
||||||
|
transition: transform 0.25s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-hero__eyebrow {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.46em;
|
||||||
|
color: rgb(30 35 80 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-hero__slogan {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-shadow: 0 8px 32px rgb(255 255 255 / 70%);
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
background: linear-gradient(120deg, var(--brand) 0%, #0b66c3 55%, #38bdf8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-hero__comma {
|
||||||
|
color: rgb(30 35 80 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-hero__sub {
|
||||||
|
margin-top: 26px;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: rgb(30 35 80 / 52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 26px;
|
||||||
|
left: 56px;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: rgb(30 35 80 / 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 登录卡片(白玻璃质感) ---------- */
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
width: 420px;
|
||||||
|
padding: 44px 40px 40px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 75%);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(168deg, rgb(255 255 255 / 82%) 0%, rgb(248 250 255 / 88%) 100%);
|
||||||
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 70px -24px rgb(23 92 171 / 26%),
|
||||||
|
0 0 0 1px rgb(30 35 80 / 5%),
|
||||||
|
0 1px 0 rgb(255 255 255 / 90%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__logo {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
filter: drop-shadow(0 10px 26px color-mix(in srgb, var(--brand) 35%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #20254d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__subtitle {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgb(30 35 80 / 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片内表单:浅色质感统一覆盖(作用于子模块) */
|
||||||
|
.login-card :deep(.el-input__wrapper) {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgb(30 128 223 / 5%);
|
||||||
|
box-shadow: 0 0 0 1px rgb(30 35 80 / 12%) inset;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand) 55%, rgb(30 35 80 / 20%)) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1.5px var(--brand) inset,
|
||||||
|
0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
color: #1f244a;
|
||||||
|
caret-color: var(--brand);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgb(30 35 80 / 34%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__prefix,
|
||||||
|
.el-input__suffix {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgb(30 35 80 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__prefix {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :deep(.el-form-item) {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :deep(.el-checkbox__label) {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgb(30 35 80 / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态跟随登录页品牌蓝,而非系统主题色 */
|
||||||
|
.login-card :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
background-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :deep(.login-submit-button) {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
text-indent: 0.32em;
|
||||||
|
background: linear-gradient(135deg, var(--brand) 0%, color-mix(in srgb, var(--brand) 68%, #0a3f8f) 100%);
|
||||||
|
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--brand) 60%, transparent);
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
filter 0.2s ease;
|
||||||
|
|
||||||
|
/* 流光扫过 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -60%;
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(100deg, transparent 0%, rgb(255 255 255 / 35%) 50%, transparent 100%);
|
||||||
|
transform: skewX(-20deg);
|
||||||
|
transition: left 0.55s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.06);
|
||||||
|
box-shadow: 0 16px 32px -10px color-mix(in srgb, var(--brand) 70%, transparent);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 130%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :deep(.login-back-button) {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-left: 0;
|
||||||
|
border: 1px solid rgb(30 35 80 / 15%);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgb(30 35 80 / 70%);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--brand) 60%, transparent);
|
||||||
|
color: var(--brand);
|
||||||
|
background: color-mix(in srgb, var(--brand) 6%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 入场动效 ---------- */
|
||||||
|
.reveal {
|
||||||
|
animation: reveal-up 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
animation-delay: var(--d, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reveal-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import type { Component } from 'vue';
|
|
||||||
import { getPaletteColorByNumber, mixColor } from '@sa/color';
|
|
||||||
import { loginModuleRecord } from '@/constants/app';
|
|
||||||
import { useAppStore } from '@/store/modules/app';
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
|
||||||
import { $t } from '@/locales';
|
|
||||||
import PwdLogin from './modules/pwd-login.vue';
|
|
||||||
import ResetPwd from './modules/reset-pwd.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: 'LoginPage' });
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The login module */
|
|
||||||
module?: UnionKey.LoginModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
|
|
||||||
interface LoginModule {
|
|
||||||
label: App.I18n.I18nKey;
|
|
||||||
component: Component;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
|
|
||||||
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
|
|
||||||
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd }
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
|
||||||
|
|
||||||
const bgThemeColor = computed(() =>
|
|
||||||
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
|
|
||||||
);
|
|
||||||
|
|
||||||
const bgColor = computed(() => {
|
|
||||||
const COLOR_WHITE = '#ffffff';
|
|
||||||
|
|
||||||
const ratio = themeStore.darkMode ? 0.5 : 0.2;
|
|
||||||
|
|
||||||
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
|
|
||||||
<WaveBg :theme-color="bgThemeColor" />
|
|
||||||
<ElCard class="relative z-4 w-auto rd-12px">
|
|
||||||
<div class="w-400px lt-sm:w-300px">
|
|
||||||
<header class="flex-y-center justify-between">
|
|
||||||
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
|
|
||||||
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
|
|
||||||
<div class="i-flex-col">
|
|
||||||
<ThemeSchemaSwitch
|
|
||||||
:theme-schema="themeStore.themeScheme"
|
|
||||||
:show-tooltip="false"
|
|
||||||
class="text-20px lt-sm:text-18px"
|
|
||||||
@switch="themeStore.toggleThemeScheme"
|
|
||||||
/>
|
|
||||||
<LangSwitch
|
|
||||||
v-if="themeStore.header.multilingual.visible"
|
|
||||||
:lang="appStore.locale"
|
|
||||||
:lang-options="appStore.localeOptions"
|
|
||||||
:show-tooltip="false"
|
|
||||||
@change-lang="appStore.changeLocale"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="pt-15px">
|
|
||||||
<div class="pt-15px">
|
|
||||||
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
|
|
||||||
<component :is="activeModule.component" />
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -36,24 +36,38 @@ async function handleSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
|
<ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
|
||||||
<ElFormItem prop="userName">
|
<ElFormItem prop="userName">
|
||||||
<ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')" />
|
<ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')">
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:account-outline" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="password">
|
<ElFormItem prop="password">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="model.password"
|
v-model="model.password"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="click"
|
show-password
|
||||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:lock-outline" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElSpace direction="vertical" :size="24" class="w-full" fill>
|
<div class="pb-18px">
|
||||||
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
|
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
|
||||||
<ElButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
|
</div>
|
||||||
{{ $t('common.confirm') }}
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="login-submit-button"
|
||||||
|
:loading="authStore.loginLoading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ $t('route.login') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElSpace>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -43,38 +43,60 @@ async function handleSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
|
<ElForm ref="formRef" :model="model" :rules="rules" size="large" @keyup.enter="handleSubmit">
|
||||||
<ElFormItem prop="phone">
|
<ElFormItem prop="phone">
|
||||||
<ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" />
|
<ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')">
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:cellphone" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="code">
|
<ElFormItem prop="code">
|
||||||
<ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')" />
|
<ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')">
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:shield-check-outline" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="password">
|
<ElFormItem prop="password">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="model.password"
|
v-model="model.password"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="click"
|
show-password
|
||||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:lock-outline" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="confirmPassword">
|
<ElFormItem prop="confirmPassword">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="model.confirmPassword"
|
v-model="model.confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="click"
|
show-password
|
||||||
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
|
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:lock-check-outline" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElSpace direction="vertical" fill :size="18" class="w-full">
|
<ElButton type="primary" size="large" class="login-submit-button" @click="handleSubmit">
|
||||||
<ElButton type="primary" size="large" round @click="handleSubmit">
|
|
||||||
{{ $t('common.confirm') }}
|
{{ $t('common.confirm') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton size="large" round @click="toggleLoginModule('pwd-login')">
|
<ElButton size="large" class="login-back-button" @click="toggleLoginModule('pwd-login')">
|
||||||
{{ $t('page.login.common.back') }}
|
{{ $t('page.login.common.back') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElSpace>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.login-back-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-left: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<LookForward />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<LookForward />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<LookForward />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
|
||||||
import { $t } from '@/locales';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const { routerPushByKey } = useRouterPush();
|
|
||||||
|
|
||||||
const routeQuery = computed(() => JSON.stringify(route.query));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<LookForward>
|
|
||||||
<div>
|
|
||||||
<ElButton @click="routerPushByKey('function_tab')">{{ $t('page.function.multiTab.backTab') }}</ElButton>
|
|
||||||
<div class="py-24px">{{ $t('page.function.multiTab.routeParam') }}: {{ routeQuery }}</div>
|
|
||||||
</div>
|
|
||||||
</LookForward>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { fetchCustomBackendError } from '@/service/api';
|
|
||||||
import { $t } from '@/locales';
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetchCustomBackendError('8888', $t('request.logoutMsg'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logoutWithModal() {
|
|
||||||
await fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshToken() {
|
|
||||||
await fetchCustomBackendError('9999', $t('request.tokenExpired'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRepeatedMessageError() {
|
|
||||||
await Promise.all([
|
|
||||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
|
||||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
|
||||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
|
||||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
|
|
||||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
|
|
||||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRepeatedModalError() {
|
|
||||||
await Promise.all([
|
|
||||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
|
|
||||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
|
|
||||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ElSpace direction="vertical" fill :size="16">
|
|
||||||
<ElCard :header="$t('request.logout')" class="card-wrapper">
|
|
||||||
<ElButton @click="logout">{{ $t('common.trigger') }}</ElButton>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard :header="$t('request.logoutWithModal')" class="card-wrapper">
|
|
||||||
<ElButton @click="logoutWithModal">{{ $t('common.trigger') }}</ElButton>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard :header="$t('request.refreshToken')" class="card-wrapper">
|
|
||||||
<ElButton @click="refreshToken">{{ $t('common.trigger') }}</ElButton>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard :header="$t('page.function.request.repeatedErrorOccurOnce')" class="card-wrapper">
|
|
||||||
<ElButton @click="handleRepeatedMessageError">{{ $t('page.function.request.repeatedError') }}(Message)</ElButton>
|
|
||||||
<ElButton class="ml-12px" @click="handleRepeatedModalError">
|
|
||||||
{{ $t('page.function.request.repeatedError') }}(Modal)
|
|
||||||
</ElButton>
|
|
||||||
</ElCard>
|
|
||||||
</ElSpace>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<LookForward />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useTabStore } from '@/store/modules/tab';
|
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
|
||||||
import { $t } from '@/locales';
|
|
||||||
|
|
||||||
defineOptions({ name: 'TabPage' });
|
|
||||||
|
|
||||||
const tabStore = useTabStore();
|
|
||||||
const { routerPushByKey } = useRouterPush();
|
|
||||||
|
|
||||||
const tabLabel = ref('');
|
|
||||||
|
|
||||||
function changeTabLabel() {
|
|
||||||
tabStore.setTabLabel(tabLabel.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTabLabel() {
|
|
||||||
tabStore.resetTabLabel();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ElSpace direction="vertical" fill :size="16">
|
|
||||||
<ElCard :header="$t('page.function.tab.tabOperate.title')" class="card-wrapper">
|
|
||||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addTab') }}</ElDivider>
|
|
||||||
<ElButton @click="routerPushByKey('system_user')">{{ $t('page.function.tab.tabOperate.addTabDesc') }}</ElButton>
|
|
||||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.closeTab') }}</ElDivider>
|
|
||||||
<ElSpace>
|
|
||||||
<ElButton @click="tabStore.removeActiveTab">
|
|
||||||
{{ $t('page.function.tab.tabOperate.closeCurrentTab') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton @click="tabStore.removeTabByRouteName('system_user')">
|
|
||||||
{{ $t('page.function.tab.tabOperate.closeAboutTab') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addMultiTab') }}</ElDivider>
|
|
||||||
<ElSpace>
|
|
||||||
<ElButton @click="routerPushByKey('function_multi-tab')">
|
|
||||||
{{ $t('page.function.tab.tabOperate.addMultiTabDesc1') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton @click="routerPushByKey('function_multi-tab', { query: { a: '1' } })">
|
|
||||||
{{ $t('page.function.tab.tabOperate.addMultiTabDesc2') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard :header="$t('page.function.tab.tabTitle.title')" class="card-wrapper">
|
|
||||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.changeTitle') }}</ElDivider>
|
|
||||||
<ElInput v-model="tabLabel" class="max-w-240px">
|
|
||||||
<template #append>
|
|
||||||
<ElButton type="primary" @click="changeTabLabel">{{ $t('page.function.tab.tabTitle.change') }}</ElButton>
|
|
||||||
</template>
|
|
||||||
</ElInput>
|
|
||||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.resetTitle') }}</ElDivider>
|
|
||||||
<ElButton type="danger" plain class="w-80px" @click="resetTabLabel">
|
|
||||||
{{ $t('page.function.tab.tabTitle.reset') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElCard>
|
|
||||||
</ElSpace>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { useLoading } from '@sa/hooks';
|
|
||||||
import { useAppStore } from '@/store/modules/app';
|
|
||||||
import { useAuthStore } from '@/store/modules/auth';
|
|
||||||
import { useTabStore } from '@/store/modules/tab';
|
|
||||||
import { useAuth } from '@/hooks/business/auth';
|
|
||||||
import { $t } from '@/locales';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ToggleAuth' });
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const tabStore = useTabStore();
|
|
||||||
const { hasAuth } = useAuth();
|
|
||||||
const { loading, startLoading, endLoading } = useLoading();
|
|
||||||
|
|
||||||
type AccountKey = 'super' | 'admin' | 'user';
|
|
||||||
|
|
||||||
interface Account {
|
|
||||||
key: AccountKey;
|
|
||||||
label: string;
|
|
||||||
userName: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = computed<Account[]>(() => [
|
|
||||||
{
|
|
||||||
key: 'super',
|
|
||||||
label: $t('page.login.pwdLogin.superAdmin'),
|
|
||||||
userName: 'Super',
|
|
||||||
password: '123456'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'admin',
|
|
||||||
label: $t('page.login.pwdLogin.admin'),
|
|
||||||
userName: 'Admin',
|
|
||||||
password: '123456'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user',
|
|
||||||
label: $t('page.login.pwdLogin.user'),
|
|
||||||
userName: 'User',
|
|
||||||
password: '123456'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const loginAccount = ref<AccountKey>('super');
|
|
||||||
|
|
||||||
async function handleToggleAccount(account: Account) {
|
|
||||||
loginAccount.value = account.key;
|
|
||||||
|
|
||||||
startLoading();
|
|
||||||
await authStore.login(account.userName, account.password, false);
|
|
||||||
tabStore.initTabStore(route);
|
|
||||||
endLoading();
|
|
||||||
appStore.reloadPage();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ElSpace direction="vertical" fill :size="16">
|
|
||||||
<ElCard :header="$t('route.function_toggle-auth')" class="card-wrapper">
|
|
||||||
<ElDescriptions direction="vertical" border :column="1">
|
|
||||||
<ElDescriptionsItem :label="$t('page.system.user.userRole')">
|
|
||||||
<ElSpace>
|
|
||||||
<ElTag v-for="role in authStore.userInfo.roles" :key="role">{{ role }}</ElTag>
|
|
||||||
</ElSpace>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem ions-item :label="$t('page.function.toggleAuth.toggleAccount')">
|
|
||||||
<ElSpace>
|
|
||||||
<ElButton
|
|
||||||
v-for="account in accounts"
|
|
||||||
:key="account.key"
|
|
||||||
:loading="loading && loginAccount === account.key"
|
|
||||||
:disabled="loading && loginAccount !== account.key"
|
|
||||||
@click="handleToggleAccount(account)"
|
|
||||||
>
|
|
||||||
{{ account.label }}
|
|
||||||
</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
</ElDescriptions>
|
|
||||||
</ElCard>
|
|
||||||
<ElCard :header="$t('page.function.toggleAuth.authHook')" class="card-wrapper">
|
|
||||||
<ElSpace>
|
|
||||||
<ElButton v-if="hasAuth('B_CODE1')">{{ $t('page.function.toggleAuth.superAdminVisible') }}</ElButton>
|
|
||||||
<ElButton v-if="hasAuth('B_CODE2')">{{ $t('page.function.toggleAuth.adminVisible') }}</ElButton>
|
|
||||||
<ElButton v-if="hasAuth('B_CODE3')">
|
|
||||||
{{ $t('page.function.toggleAuth.adminOrUserVisible') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElSpace>
|
|
||||||
</ElCard>
|
|
||||||
</ElSpace>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -17,6 +17,9 @@ import StateMachineOperateDialog from './modules/state-machine-operate-dialog.vu
|
|||||||
import StateMachineSearch from './modules/state-machine-search.vue';
|
import StateMachineSearch from './modules/state-machine-search.vue';
|
||||||
import StateTransitionDialog from './modules/state-transition-dialog.vue';
|
import StateTransitionDialog from './modules/state-transition-dialog.vue';
|
||||||
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
|
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' });
|
defineOptions({ name: 'StateMachineManage' });
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
|||||||
actions.push({
|
actions.push({
|
||||||
key: 'transition',
|
key: 'transition',
|
||||||
label: '状态流转',
|
label: '状态流转',
|
||||||
|
icon: IconMdiSourceBranch,
|
||||||
buttonType: 'primary',
|
buttonType: 'primary',
|
||||||
onClick: () => openTransitionDialog(row)
|
onClick: () => openTransitionDialog(row)
|
||||||
});
|
});
|
||||||
@@ -80,6 +84,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
|||||||
actions.push({
|
actions.push({
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: '编辑',
|
label: '编辑',
|
||||||
|
icon: IconMdiPencilOutline,
|
||||||
buttonType: 'primary',
|
buttonType: 'primary',
|
||||||
onClick: () => openEdit(row)
|
onClick: () => openEdit(row)
|
||||||
});
|
});
|
||||||
@@ -89,6 +94,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
|||||||
actions.push({
|
actions.push({
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: '删除',
|
label: '删除',
|
||||||
|
icon: IconMdiDeleteOutline,
|
||||||
buttonType: 'danger',
|
buttonType: 'danger',
|
||||||
onClick: () => handleDeleteAction(row)
|
onClick: () => handleDeleteAction(row)
|
||||||
});
|
});
|
||||||
@@ -203,7 +209,7 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
|||||||
return <span>--</span>;
|
return <span>--</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BusinessTableActionCell actions={actions} />;
|
return <BusinessTableActionCell actions={actions} variant="icon" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
667
src/views/personal-center/my-item/index.vue
Normal file
667
src/views/personal-center/my-item/index.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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(/ /g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !/<img\b/i.test(html);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<LookForward title="我的月报" subtitle="功能建设中,敬请期待" />
|
|
||||||
</template>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<LookForward title="我的周报" subtitle="功能建设中,敬请期待" />
|
|
||||||
</template>
|
|
||||||
321
src/views/personal-center/overtime-application/index.vue
Normal file
321
src/views/personal-center/overtime-application/index.vue
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, markRaw, reactive, ref } from 'vue';
|
||||||
|
import { ElButton, ElTag } from 'element-plus';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { fetchExportOvertimeApplications, fetchGetOvertimeApplicationPage } from '@/service/api';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
|
import OvertimeApplicationApprovalRecordDialog from './modules/overtime-application-approval-record-dialog.vue';
|
||||||
|
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
|
||||||
|
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
|
||||||
|
import OvertimeApplicationSearch from './modules/overtime-application-search.vue';
|
||||||
|
import {
|
||||||
|
downloadBlob,
|
||||||
|
formatEmptyText,
|
||||||
|
formatOvertimeDate,
|
||||||
|
formatOvertimeDateTime,
|
||||||
|
getOvertimeApplicationStatusLabel,
|
||||||
|
resolveOvertimeApplicationStatusTagType
|
||||||
|
} from './modules/overtime-application-shared';
|
||||||
|
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||||
|
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
|
||||||
|
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OvertimeApplication' });
|
||||||
|
|
||||||
|
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
|
||||||
|
|
||||||
|
function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: undefined,
|
||||||
|
applicantName: undefined,
|
||||||
|
approverId: undefined,
|
||||||
|
approverName: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
overtimeDate: undefined,
|
||||||
|
createTime: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPageResult(response: OvertimeApplicationPageResponse, pageNo: number, pageSize: number) {
|
||||||
|
if (!response.error && response.data) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitSearchParams());
|
||||||
|
const operateVisible = ref(false);
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const approvalRecordVisible = ref(false);
|
||||||
|
const operateType = ref<'add' | 'edit'>('add');
|
||||||
|
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||||
|
const exporting = ref(false);
|
||||||
|
|
||||||
|
const ACTION_ICON_MAP = {
|
||||||
|
detail: markRaw(IconMdiEyeOutline),
|
||||||
|
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
|
||||||
|
edit: markRaw(IconMdiPencilOutline)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||||
|
OvertimeApplicationPageResponse,
|
||||||
|
Api.OvertimeApplication.OvertimeApplication
|
||||||
|
>({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => fetchGetOvertimeApplicationPage(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: 'index', type: 'index', label: '序号', width: 64 },
|
||||||
|
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'overtimeDate',
|
||||||
|
label: '加班日期',
|
||||||
|
width: 120,
|
||||||
|
formatter: row => formatOvertimeDate(row.overtimeDate)
|
||||||
|
},
|
||||||
|
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'overtimeReason',
|
||||||
|
label: '加班原因',
|
||||||
|
minWidth: 180,
|
||||||
|
className: 'overtime-application__cell-ellipsis',
|
||||||
|
formatter: row => formatEmptyText(row.overtimeReason)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'overtimeContent',
|
||||||
|
label: '加班内容',
|
||||||
|
minWidth: 200,
|
||||||
|
className: 'overtime-application__cell-ellipsis',
|
||||||
|
formatter: row => formatEmptyText(row.overtimeContent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'statusCode',
|
||||||
|
label: '状态',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => (
|
||||||
|
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
|
||||||
|
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
|
||||||
|
</ElTag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ prop: 'approverName', label: '审核人', minWidth: 80, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'submitTime',
|
||||||
|
label: '提交时间',
|
||||||
|
minWidth: 150,
|
||||||
|
formatter: row => formatOvertimeDateTime(row.submitTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'approvalTime',
|
||||||
|
label: '审核时间',
|
||||||
|
minWidth: 150,
|
||||||
|
formatter: row => formatOvertimeDateTime(row.approvalTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'operate',
|
||||||
|
label: '操作',
|
||||||
|
width: 170,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right',
|
||||||
|
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
|
||||||
|
|
||||||
|
function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): BusinessTableAction[] {
|
||||||
|
const actions: BusinessTableAction[] = [
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
label: '详情',
|
||||||
|
buttonType: 'primary',
|
||||||
|
icon: ACTION_ICON_MAP.detail,
|
||||||
|
onClick: () => openDetail(row)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (row.statusCode === 'rejected' && row.allowEdit) {
|
||||||
|
actions.push({
|
||||||
|
key: 'edit',
|
||||||
|
label: '修改',
|
||||||
|
buttonType: 'primary',
|
||||||
|
icon: ACTION_ICON_MAP.edit,
|
||||||
|
onClick: () => openEdit(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['approved', 'rejected'].includes(row.statusCode)) {
|
||||||
|
actions.push({
|
||||||
|
key: 'approval-record',
|
||||||
|
label: '审批记录',
|
||||||
|
buttonType: 'info',
|
||||||
|
icon: ACTION_ICON_MAP.approvalRecord,
|
||||||
|
onClick: () => openApprovalRecord(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
operateType.value = 'add';
|
||||||
|
currentRow.value = null;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||||
|
operateType.value = 'edit';
|
||||||
|
currentRow.value = row;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||||
|
currentRow.value = row;
|
||||||
|
detailVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApprovalRecord(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||||
|
currentRow.value = row;
|
||||||
|
approvalRecordVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadTable(page = searchParams.pageNo ?? 1) {
|
||||||
|
await getDataByPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
const pageSize = searchParams.pageSize ?? 10;
|
||||||
|
Object.assign(searchParams, getInitSearchParams(), { pageSize });
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted() {
|
||||||
|
operateVisible.value = false;
|
||||||
|
reloadTable(searchParams.pageNo ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
exporting.value = true;
|
||||||
|
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
|
||||||
|
exporting.value = false;
|
||||||
|
|
||||||
|
if (error || !blob) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||||
|
<OvertimeApplicationSearch 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 flex-wrap items-center justify-between gap-12px">
|
||||||
|
<div class="flex items-center gap-10px">
|
||||||
|
<p class="text-16px font-600">加班申请</p>
|
||||||
|
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||||
|
</div>
|
||||||
|
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||||
|
<template #default>
|
||||||
|
<ElButton plain :loading="exporting" @click="handleExport">
|
||||||
|
<template #icon>
|
||||||
|
<icon-mdi-download 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 v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||||
|
<template v-for="col in columns" :key="String(col.prop)">
|
||||||
|
<ElTableColumn 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>
|
||||||
|
|
||||||
|
<OvertimeApplicationOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:operate-type="operateType"
|
||||||
|
:row-data="currentRow"
|
||||||
|
@submitted="handleSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" />
|
||||||
|
|
||||||
|
<OvertimeApplicationApprovalRecordDialog v-model:visible="approvalRecordVisible" :row-data="currentRow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.overtime-application__reason-link) {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application__reason-link > span) {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加班原因/加班内容:单元格内容溢出时仅显示省略号,不弹出 tooltip */
|
||||||
|
:deep(.overtime-application__cell-ellipsis .cell) {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<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: 'OvertimeApplicationActionDialog' });
|
||||||
|
|
||||||
|
type ActionType = 'approve' | 'reject';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
actionType: ActionType;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [reason: string | null];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const map: Record<ActionType, string> = {
|
||||||
|
approve: '通过加班申请',
|
||||||
|
reject: '退回加班申请'
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[props.actionType];
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasonLabel = computed(() => {
|
||||||
|
const map: Record<ActionType, string> = {
|
||||||
|
approve: '审核意见',
|
||||||
|
reject: '退回原因'
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[props.actionType];
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasonRequired = computed(() => props.actionType === 'reject');
|
||||||
|
|
||||||
|
const reasonPlaceholder = computed(() => {
|
||||||
|
if (reasonRequired.value) {
|
||||||
|
return `请输入${reasonLabel.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '可填写审核意见';
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = computed(() => ({
|
||||||
|
reason: reasonRequired.value
|
||||||
|
? [
|
||||||
|
createRequiredRule(`请输入${reasonLabel.value}`),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
callback(new Error(`请输入${reasonLabel.value}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
emit('submit', model.reason.trim() || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (value) {
|
||||||
|
model.reason = '';
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
preset="sm"
|
||||||
|
:confirm-loading="props.loading"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
|
<ElFormItem :label="reasonLabel" prop="reason">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="reasonPlaceholder"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { fetchGetOvertimeApplicationApprovalRecords } from '@/service/api';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import {
|
||||||
|
formatEmptyText,
|
||||||
|
formatOvertimeDateTime,
|
||||||
|
getOvertimeApplicationStatusLabel
|
||||||
|
} from './overtime-application-shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OvertimeApplicationApprovalRecordDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const records = ref<Api.OvertimeApplication.OvertimeApplicationApprovalRecord[]>([]);
|
||||||
|
|
||||||
|
async function loadRecords() {
|
||||||
|
if (!props.rowData?.id) {
|
||||||
|
records.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
const { error, data } = await fetchGetOvertimeApplicationApprovalRecords(props.rowData.id);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
records.value = error || !data ? [] : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
loadRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="加班申请审批记录"
|
||||||
|
width="820px"
|
||||||
|
:loading="loading"
|
||||||
|
:show-footer="false"
|
||||||
|
max-body-height="72vh"
|
||||||
|
>
|
||||||
|
<ElTable border :data="records">
|
||||||
|
<ElTableColumn prop="approvalRound" label="轮次" width="80" />
|
||||||
|
<ElTableColumn label="结论" width="110">
|
||||||
|
<template #default="{ row }">{{ getOvertimeApplicationStatusLabel(row.conclusion) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="审批意见" min-width="240" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ formatEmptyText(row.opinion) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="auditorName" label="审批人" width="130" show-overflow-tooltip />
|
||||||
|
<ElTableColumn label="审批时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||||
|
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||||
|
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||||
|
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
|
||||||
|
import IconMdiChevronRight from '~icons/mdi/chevron-right';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 选中的加班申请 id 列表(原始 id) */
|
||||||
|
selectedIds: string[];
|
||||||
|
/** 全部加班申请行数据,用于通过 id 查找 */
|
||||||
|
rows: Api.OvertimeApplication.OvertimeApplication[];
|
||||||
|
actionLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
actionLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
approve: [];
|
||||||
|
reject: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
|
||||||
|
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
|
||||||
|
|
||||||
|
const total = computed(() => props.selectedIds.length);
|
||||||
|
|
||||||
|
const canGoPrev = computed(() => currentIndex.value > 0);
|
||||||
|
const canGoNext = computed(() => currentIndex.value < props.selectedIds.length - 1);
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
const id = currentId.value;
|
||||||
|
if (!id) {
|
||||||
|
detailData.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = props.rows.find(r => r.id === id);
|
||||||
|
if (!row) {
|
||||||
|
detailData.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true;
|
||||||
|
const { error, data } = await fetchGetOvertimeApplicationDetail(id);
|
||||||
|
detailLoading.value = false;
|
||||||
|
|
||||||
|
detailData.value = error || !data ? row : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
if (!canGoPrev.value) return;
|
||||||
|
currentIndex.value -= 1;
|
||||||
|
loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (!canGoNext.value) return;
|
||||||
|
currentIndex.value += 1;
|
||||||
|
loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
currentIndex.value = 0;
|
||||||
|
loadDetail();
|
||||||
|
} else {
|
||||||
|
detailData.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" title="批量审批" preset="md" :loading="detailLoading" :show-footer="true">
|
||||||
|
<!-- 左右导航 -->
|
||||||
|
<div class="batch-detail__nav">
|
||||||
|
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoPrev" @click.stop="goPrev">
|
||||||
|
<IconMdiChevronLeft class="text-20px" />
|
||||||
|
</button>
|
||||||
|
<span class="batch-detail__nav-counter">{{ currentIndex + 1 }} / {{ total }}</span>
|
||||||
|
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoNext" @click.stop="goNext">
|
||||||
|
<IconMdiChevronRight class="text-20px" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
|
||||||
|
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.applicantName }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||||
|
{{ formatOvertimeDate(detailData.overtimeDate) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeDuration }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||||
|
{{ formatOvertimeDateTime(detailData.submitTime) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeReason }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeContent }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="batch-detail__footer">
|
||||||
|
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
|
||||||
|
<div class="batch-detail__footer-actions">
|
||||||
|
<ElButton
|
||||||
|
class="batch-detail__approve-btn"
|
||||||
|
type="success"
|
||||||
|
:loading="props.actionLoading"
|
||||||
|
:disabled="props.actionLoading || !detailData"
|
||||||
|
@click="emit('approve')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconMdiCheckCircleOutline />
|
||||||
|
</template>
|
||||||
|
通过
|
||||||
|
</ElButton>
|
||||||
|
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||||
|
<template #icon>
|
||||||
|
<IconMdiCloseCircleOutline />
|
||||||
|
</template>
|
||||||
|
退回
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.batch-detail__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 90%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgb(255 255 255 / 98%);
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__nav-btn:hover:not(:disabled) {
|
||||||
|
border-color: rgb(14 116 144 / 60%);
|
||||||
|
color: rgb(14 116 144 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__nav-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__nav-counter {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(15 23 42 / 96%);
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__footer-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-detail__approve-btn {
|
||||||
|
--el-button-bg-color: #0f766e;
|
||||||
|
--el-button-border-color: #0f766e;
|
||||||
|
--el-button-hover-bg-color: #115e59;
|
||||||
|
--el-button-hover-border-color: #115e59;
|
||||||
|
--el-button-active-bg-color: #134e4a;
|
||||||
|
--el-button-active-border-color: #134e4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label),
|
||||||
|
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label) {
|
||||||
|
width: 96px;
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||||
|
width: 86px;
|
||||||
|
min-width: 86px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||||
|
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||||
|
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||||
|
showApprovalActions?: boolean;
|
||||||
|
actionLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showApprovalActions: false,
|
||||||
|
actionLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
approve: [];
|
||||||
|
reject: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!props.rowData?.id) {
|
||||||
|
detailData.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
detailData.value = error || !data ? props.rowData : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
loadDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="加班申请详情"
|
||||||
|
preset="md"
|
||||||
|
:loading="loading"
|
||||||
|
:show-footer="props.showApprovalActions"
|
||||||
|
>
|
||||||
|
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
|
||||||
|
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.applicantName }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||||
|
{{ formatOvertimeDate(detailData.overtimeDate) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeDuration }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
|
||||||
|
{{ formatOvertimeDateTime(detailData.submitTime) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeReason }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
|
||||||
|
{{ detailData.overtimeContent }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="overtime-application-detail-dialog__footer">
|
||||||
|
<ElButton
|
||||||
|
class="overtime-application-detail-dialog__approve-btn"
|
||||||
|
type="success"
|
||||||
|
:loading="props.actionLoading"
|
||||||
|
:disabled="props.actionLoading || !detailData"
|
||||||
|
@click="emit('approve')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconMdiCheckCircleOutline />
|
||||||
|
</template>
|
||||||
|
通过
|
||||||
|
</ElButton>
|
||||||
|
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||||
|
<template #icon>
|
||||||
|
<IconMdiCloseCircleOutline />
|
||||||
|
</template>
|
||||||
|
退回
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overtime-application-detail-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overtime-application-detail-dialog__approve-btn {
|
||||||
|
--el-button-bg-color: #0f766e;
|
||||||
|
--el-button-border-color: #0f766e;
|
||||||
|
--el-button-hover-bg-color: #115e59;
|
||||||
|
--el-button-hover-border-color: #115e59;
|
||||||
|
--el-button-active-bg-color: #134e4a;
|
||||||
|
--el-button-active-border-color: #134e4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label),
|
||||||
|
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label) {
|
||||||
|
width: 96px;
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__label--compact) {
|
||||||
|
width: 86px;
|
||||||
|
min-width: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.overtime-application-detail-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-detail-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
|
||||||
|
import {
|
||||||
|
fetchCreateOvertimeApplication,
|
||||||
|
fetchGetLoginUserDirectManager,
|
||||||
|
fetchGetOvertimeApplicationDetail,
|
||||||
|
fetchUpdateRejectedOvertimeApplication
|
||||||
|
} 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';
|
||||||
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OvertimeApplicationOperateDialog' });
|
||||||
|
|
||||||
|
type OperateType = 'add' | 'edit';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: OperateType;
|
||||||
|
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const approverName = ref('');
|
||||||
|
|
||||||
|
const currentUserName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '--');
|
||||||
|
const isEdit = computed(() => props.operateType === 'edit');
|
||||||
|
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增加班申请'));
|
||||||
|
|
||||||
|
const model = reactive<Api.OvertimeApplication.SaveOvertimeApplicationParams>(createDefaultModel());
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
overtimeDate: [createRequiredRule('请选择加班日期')],
|
||||||
|
overtimeDuration: [createRequiredRule('请选择加班时长')],
|
||||||
|
overtimeReason: [
|
||||||
|
createRequiredRule('请输入加班原因'),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
callback(new Error('请输入加班原因'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
overtimeContent: [
|
||||||
|
createRequiredRule('请输入加班内容'),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
callback(new Error('请输入加班内容'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
approverId: [createRequiredRule('请选择审核人')]
|
||||||
|
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
|
||||||
|
return {
|
||||||
|
overtimeDate: dayjs().format('YYYY-MM-DD'),
|
||||||
|
overtimeDuration: '0.5',
|
||||||
|
overtimeReason: '',
|
||||||
|
overtimeContent: '',
|
||||||
|
approverId: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDirectManagerAsDefaultApprover() {
|
||||||
|
const { error, data } = await fetchGetLoginUserDirectManager();
|
||||||
|
|
||||||
|
if (!error && data?.id) {
|
||||||
|
model.approverId = data.id;
|
||||||
|
approverName.value = data.nickname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initModel() {
|
||||||
|
detailLoading.value = true;
|
||||||
|
Object.assign(model, createDefaultModel());
|
||||||
|
approverName.value = '';
|
||||||
|
|
||||||
|
if (isEdit.value && props.rowData) {
|
||||||
|
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
|
||||||
|
const detail = error || !data ? props.rowData : data;
|
||||||
|
|
||||||
|
model.overtimeDate = detail.overtimeDate;
|
||||||
|
model.overtimeDuration = detail.overtimeDuration;
|
||||||
|
model.overtimeReason = detail.overtimeReason;
|
||||||
|
model.overtimeContent = detail.overtimeContent;
|
||||||
|
model.approverId = detail.approverId;
|
||||||
|
approverName.value = detail.approverName;
|
||||||
|
} else {
|
||||||
|
await loadDirectManagerAsDefaultApprover();
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = false;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
const payload: Api.OvertimeApplication.SaveOvertimeApplicationParams = {
|
||||||
|
overtimeDate: model.overtimeDate,
|
||||||
|
overtimeDuration: model.overtimeDuration,
|
||||||
|
overtimeReason: model.overtimeReason.trim(),
|
||||||
|
overtimeContent: model.overtimeContent.trim(),
|
||||||
|
approverId: model.approverId
|
||||||
|
};
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isEdit.value && props.rowData
|
||||||
|
? await fetchUpdateRejectedOvertimeApplication(props.rowData.id, payload)
|
||||||
|
: await fetchCreateOvertimeApplication(payload);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
preset="md"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
|
<BusinessFormSection title="申请信息">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="申请人">
|
||||||
|
<ElInput
|
||||||
|
class="overtime-application-operate-dialog__readonly-input"
|
||||||
|
:model-value="currentUserName"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="审核人" prop="approverId">
|
||||||
|
<ElInput
|
||||||
|
class="overtime-application-operate-dialog__readonly-input"
|
||||||
|
:model-value="approverName"
|
||||||
|
readonly
|
||||||
|
placeholder="暂无直属上级"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="加班日期" prop="overtimeDate" style="width: 100%">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.overtimeDate"
|
||||||
|
class="w-full"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请选择加班日期"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="加班时长" prop="overtimeDuration">
|
||||||
|
<DictSelect
|
||||||
|
v-model="model.overtimeDuration"
|
||||||
|
:dict-code="RDMS_OVERTIME_DURATION_DICT_CODE"
|
||||||
|
placeholder="请选择加班时长"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</BusinessFormSection>
|
||||||
|
|
||||||
|
<BusinessFormSection title="加班说明">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="加班原因" prop="overtimeReason">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.overtimeReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入加班原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="加班内容" prop="overtimeContent">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.overtimeContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入加班内容"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.overtime-application-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.overtime-application-operate-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user