Compare commits
18 Commits
71da2d507e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e369b23a9 | |||
| b72ad00912 | |||
| 7cc29e0a35 | |||
| 39458386ae | |||
| acef4418d8 | |||
| 9d84b1aae0 | |||
| d3d0830820 | |||
| b2da882b31 | |||
| 4ed4b537ad | |||
| 3988eaf910 | |||
| e9214137c1 | |||
| 13b74cfe97 | |||
|
|
ab882e085b | ||
| 62859bfc38 | |||
| ba328e02bb | |||
|
|
28d597d91e | ||
|
|
fe29fde564 | ||
|
|
7d578ab271 |
14
CLAUDE.md
14
CLAUDE.md
@@ -415,3 +415,17 @@ pnpm preview # preview server (9725)
|
||||
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
||||
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
||||
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
|
||||
|
||||
---
|
||||
|
||||
## 20. 我生成文档的输出格式(强约束)
|
||||
|
||||
- **superpowers 工作流(`docs/superpowers/plans/`、`docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
|
||||
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html`、`技术负债台账.html`)的样式骨架:
|
||||
- 单文件、内联 CSS
|
||||
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
|
||||
- 14px / `line-height: 1.7`、`PingFang SC` / `Microsoft YaHei` 中文字体优先
|
||||
- 模块化区块:`section` + 编号 h2、`card`、`table.cmp`、`pre`、`tag-ok/warn/bad/crit`
|
||||
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
|
||||
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
|
||||
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。
|
||||
|
||||
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,39 @@ export function setupElegantRouter() {
|
||||
order: 0,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-item': {
|
||||
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-weekly': {
|
||||
icon: 'mdi:calendar-week-outline',
|
||||
order: 1,
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-monthly': {
|
||||
icon: 'mdi:calendar-month-outline',
|
||||
order: 2,
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-performance': {
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 3,
|
||||
order: 4,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-application': {
|
||||
icon: 'mdi:file-document-outline',
|
||||
order: 4,
|
||||
order: 5,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_overtime-application': {
|
||||
icon: 'mdi:clock-plus-outline',
|
||||
order: 6,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_pending-approval': {
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 5,
|
||||
order: 7,
|
||||
keepAlive: true
|
||||
},
|
||||
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.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
"pageComponentPattern": "view.<routeName>",
|
||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||
},
|
||||
"total": 21,
|
||||
"total": 23,
|
||||
"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",
|
||||
"path": "/product/list",
|
||||
@@ -111,7 +78,7 @@
|
||||
"name": "ticket_my-submitted",
|
||||
"path": "/ticket/my-submitted",
|
||||
"component": "view.ticket_my-submitted",
|
||||
"title": "ticket_my-submitted",
|
||||
"title": "我提交的工单",
|
||||
"routeTitle": "ticket_my-submitted",
|
||||
"i18nKey": "route.ticket_my-submitted",
|
||||
"icon": "mdi:upload-outline",
|
||||
@@ -125,7 +92,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "ticket_my-submitted",
|
||||
"title": "我提交的工单",
|
||||
"i18nKey": "route.ticket_my-submitted",
|
||||
"icon": "mdi:upload-outline",
|
||||
"localIcon": null,
|
||||
@@ -144,7 +111,7 @@
|
||||
"name": "ticket_my-pending",
|
||||
"path": "/ticket/my-pending",
|
||||
"component": "view.ticket_my-pending",
|
||||
"title": "ticket_my-pending",
|
||||
"title": "待我处理的工单",
|
||||
"routeTitle": "ticket_my-pending",
|
||||
"i18nKey": "route.ticket_my-pending",
|
||||
"icon": "mdi:inbox-arrow-down-outline",
|
||||
@@ -158,7 +125,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "ticket_my-pending",
|
||||
"title": "待我处理的工单",
|
||||
"i18nKey": "route.ticket_my-pending",
|
||||
"icon": "mdi:inbox-arrow-down-outline",
|
||||
"localIcon": null,
|
||||
@@ -177,7 +144,7 @@
|
||||
"name": "metrics_project-progress",
|
||||
"path": "/metrics/project-progress",
|
||||
"component": "view.metrics_project-progress",
|
||||
"title": "metrics_project-progress",
|
||||
"title": "项目进度",
|
||||
"routeTitle": "metrics_project-progress",
|
||||
"i18nKey": "route.metrics_project-progress",
|
||||
"icon": "mdi:progress-clock",
|
||||
@@ -191,7 +158,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "metrics_project-progress",
|
||||
"title": "项目进度",
|
||||
"i18nKey": "route.metrics_project-progress",
|
||||
"icon": "mdi:progress-clock",
|
||||
"localIcon": null,
|
||||
@@ -210,7 +177,7 @@
|
||||
"name": "metrics_member-efficiency",
|
||||
"path": "/metrics/member-efficiency",
|
||||
"component": "view.metrics_member-efficiency",
|
||||
"title": "metrics_member-efficiency",
|
||||
"title": "员工能效",
|
||||
"routeTitle": "metrics_member-efficiency",
|
||||
"i18nKey": "route.metrics_member-efficiency",
|
||||
"icon": "mdi:account-multiple-check-outline",
|
||||
@@ -224,7 +191,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "metrics_member-efficiency",
|
||||
"title": "员工能效",
|
||||
"i18nKey": "route.metrics_member-efficiency",
|
||||
"icon": "mdi:account-multiple-check-outline",
|
||||
"localIcon": null,
|
||||
@@ -243,7 +210,7 @@
|
||||
"name": "metrics_worktime",
|
||||
"path": "/metrics/worktime",
|
||||
"component": "view.metrics_worktime",
|
||||
"title": "metrics_worktime",
|
||||
"title": "工时统计",
|
||||
"routeTitle": "metrics_worktime",
|
||||
"i18nKey": "route.metrics_worktime",
|
||||
"icon": "mdi:clock-time-five-outline",
|
||||
@@ -257,7 +224,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "metrics_worktime",
|
||||
"title": "工时统计",
|
||||
"i18nKey": "route.metrics_worktime",
|
||||
"icon": "mdi:clock-time-five-outline",
|
||||
"localIcon": null,
|
||||
@@ -272,11 +239,77 @@
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-profile",
|
||||
"path": "/personal-center/my-profile",
|
||||
"component": "view.personal-center_my-profile",
|
||||
"title": "个人信息",
|
||||
"routeTitle": "personal-center_my-profile",
|
||||
"i18nKey": "route.personal-center_my-profile",
|
||||
"icon": "mdi:account-box-outline",
|
||||
"localIcon": null,
|
||||
"order": 0,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "个人信息",
|
||||
"i18nKey": "route.personal-center_my-profile",
|
||||
"icon": "mdi:account-box-outline",
|
||||
"localIcon": null,
|
||||
"order": 0,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-item",
|
||||
"path": "/personal-center/my-item",
|
||||
"component": "view.personal-center_my-item",
|
||||
"title": "我的事项",
|
||||
"routeTitle": "personal-center_my-item",
|
||||
"i18nKey": "route.personal-center_my-item",
|
||||
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的事项",
|
||||
"i18nKey": "route.personal-center_my-item",
|
||||
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-weekly",
|
||||
"path": "/personal-center/my-weekly",
|
||||
"component": "view.personal-center_my-weekly",
|
||||
"title": "personal-center_my-weekly",
|
||||
"title": "我的周报",
|
||||
"routeTitle": "personal-center_my-weekly",
|
||||
"i18nKey": "route.personal-center_my-weekly",
|
||||
"icon": "mdi:calendar-week-outline",
|
||||
@@ -290,7 +323,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "personal-center_my-weekly",
|
||||
"title": "我的周报",
|
||||
"i18nKey": "route.personal-center_my-weekly",
|
||||
"icon": "mdi:calendar-week-outline",
|
||||
"localIcon": null,
|
||||
@@ -309,7 +342,7 @@
|
||||
"name": "personal-center_my-monthly",
|
||||
"path": "/personal-center/my-monthly",
|
||||
"component": "view.personal-center_my-monthly",
|
||||
"title": "personal-center_my-monthly",
|
||||
"title": "我的月报",
|
||||
"routeTitle": "personal-center_my-monthly",
|
||||
"i18nKey": "route.personal-center_my-monthly",
|
||||
"icon": "mdi:calendar-month-outline",
|
||||
@@ -323,7 +356,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "personal-center_my-monthly",
|
||||
"title": "我的月报",
|
||||
"i18nKey": "route.personal-center_my-monthly",
|
||||
"icon": "mdi:calendar-month-outline",
|
||||
"localIcon": null,
|
||||
@@ -342,7 +375,7 @@
|
||||
"name": "personal-center_my-performance",
|
||||
"path": "/personal-center/my-performance",
|
||||
"component": "view.personal-center_my-performance",
|
||||
"title": "personal-center_my-performance",
|
||||
"title": "我的绩效",
|
||||
"routeTitle": "personal-center_my-performance",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
@@ -356,7 +389,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "personal-center_my-performance",
|
||||
"title": "我的绩效",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
"localIcon": null,
|
||||
@@ -375,7 +408,7 @@
|
||||
"name": "personal-center_my-application",
|
||||
"path": "/personal-center/my-application",
|
||||
"component": "view.personal-center_my-application",
|
||||
"title": "personal-center_my-application",
|
||||
"title": "我的申请",
|
||||
"routeTitle": "personal-center_my-application",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
@@ -389,7 +422,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "personal-center_my-application",
|
||||
"title": "我的申请",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"localIcon": null,
|
||||
@@ -405,15 +438,15 @@
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_pending-approval",
|
||||
"path": "/personal-center/pending-approval",
|
||||
"component": "view.personal-center_pending-approval",
|
||||
"title": "personal-center_pending-approval",
|
||||
"routeTitle": "personal-center_pending-approval",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"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": 5,
|
||||
"order": 6,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
@@ -422,11 +455,44 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "personal-center_pending-approval",
|
||||
"title": "加班申请",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_pending-approval",
|
||||
"path": "/personal-center/pending-approval",
|
||||
"component": "view.personal-center_pending-approval",
|
||||
"title": "待我审批",
|
||||
"routeTitle": "personal-center_pending-approval",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"localIcon": null,
|
||||
"order": 7,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "待我审批",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"order": 7,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
@@ -639,7 +705,7 @@
|
||||
"name": "infra_state-machine",
|
||||
"path": "/infra/state-machine",
|
||||
"component": "view.infra_state-machine",
|
||||
"title": "infra_state-machine",
|
||||
"title": "状态机管理",
|
||||
"routeTitle": "infra_state-machine",
|
||||
"i18nKey": "route.infra_state-machine",
|
||||
"icon": "mdi:state-machine",
|
||||
@@ -653,7 +719,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "infra_state-machine",
|
||||
"title": "状态机管理",
|
||||
"i18nKey": "route.infra_state-machine",
|
||||
"icon": "mdi:state-machine",
|
||||
"localIcon": null,
|
||||
@@ -672,7 +738,7 @@
|
||||
"name": "infra_rd-code",
|
||||
"path": "/infra/rd-code",
|
||||
"component": "view.infra_rd-code",
|
||||
"title": "infra_rd-code",
|
||||
"title": "研发令号",
|
||||
"routeTitle": "infra_rd-code",
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
@@ -686,7 +752,7 @@
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "infra_rd-code",
|
||||
"title": "研发令号",
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/data-set": "0.11.8",
|
||||
"@antv/g2": "5.4.0",
|
||||
"@antv/g6": "5.0.49",
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@iconify-vue/mingcute": "^1.0.5",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@sa/axios": "workspace:*",
|
||||
"@sa/color": "workspace:*",
|
||||
"@sa/hooks": "workspace:*",
|
||||
"@sa/materials": "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",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.18",
|
||||
"defu": "^6.1.4",
|
||||
"dhtmlx-gantt": "9.0.14",
|
||||
"dompurify": "3.2.6",
|
||||
"echarts": "6.0.0",
|
||||
"element-plus": "^2.11.1",
|
||||
"jsbarcode": "3.12.1",
|
||||
"grid-layout-plus": "^1.1.1",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"json5": "2.2.3",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.3",
|
||||
"pinyin-pro": "3.27.0",
|
||||
"print-js": "1.6.0",
|
||||
"swiper": "11.2.10",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"typeit": "8.8.7",
|
||||
"vditor": "3.11.2",
|
||||
"vue": "3.5.20",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.1.11",
|
||||
"vue-pdf-embed": "2.1.3",
|
||||
"vue-router": "4.5.1",
|
||||
"xgplayer": "3.0.23",
|
||||
"xlsx": "0.18.5"
|
||||
"vue-router": "4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@amap/amap-jsapi-types": "0.0.15",
|
||||
"@elegant-router/vue": "0.3.8",
|
||||
"@iconify/json": "2.2.380",
|
||||
"@sa/scripts": "workspace:*",
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/bmapgl": "0.0.7",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.5.0",
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
||||
element-plus:
|
||||
specifier: ^2.11.1
|
||||
version: 2.13.6(typescript@5.8.3)(vue@3.5.20(typescript@5.8.3))
|
||||
grid-layout-plus:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(vue@3.5.20(typescript@5.8.3))
|
||||
jsbarcode:
|
||||
specifier: 3.12.1
|
||||
version: 3.12.1
|
||||
@@ -882,6 +885,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3'
|
||||
|
||||
'@interactjs/types@1.10.27':
|
||||
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
|
||||
|
||||
'@intlify/core-base@11.1.11':
|
||||
resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -921,6 +927,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@naoak/workerize-transferable@0.1.0':
|
||||
resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==}
|
||||
peerDependencies:
|
||||
@@ -1817,6 +1826,14 @@ packages:
|
||||
peerDependencies:
|
||||
'@uppy/core': ^2.3.3
|
||||
|
||||
'@vexip-ui/hooks@2.9.4':
|
||||
resolution: {integrity: sha512-dGUiBAeHIsnSVigGSPHcuHBVqrSGW8LV+zGohvOpBfXs8Ynn5ZcSmybIWJ3G826NsicPu9rqwcJG8uvSgG4k4Q==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vexip-ui/utils@2.16.4':
|
||||
resolution: {integrity: sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==}
|
||||
|
||||
'@visactor/vchart-theme@1.12.2':
|
||||
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
|
||||
peerDependencies:
|
||||
@@ -3493,6 +3510,11 @@ packages:
|
||||
graphlib@2.1.8:
|
||||
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
|
||||
|
||||
grid-layout-plus@1.1.1:
|
||||
resolution: {integrity: sha512-7CWehJubrVC8Ps5QFUlnDsp0kiREvKfi3Pdjp21EyY8BNzSusqI3Utcxvu1Y9UUKe3YExvbhJzIxHK6rorbRaQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
gzip-size@6.0.0:
|
||||
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3629,6 +3651,9 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
interactjs@1.10.27:
|
||||
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6226,6 +6251,8 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
'@interactjs/types@1.10.27': {}
|
||||
|
||||
'@intlify/core-base@11.1.11':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 11.1.11
|
||||
@@ -6273,6 +6300,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.105.4))':
|
||||
dependencies:
|
||||
workerize-loader: 2.0.2(webpack@5.105.4)
|
||||
@@ -7082,6 +7111,15 @@ snapshots:
|
||||
'@uppy/utils': 4.1.3
|
||||
nanoid: 3.3.11
|
||||
|
||||
'@vexip-ui/hooks@2.9.4(vue@3.5.20(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@juggle/resize-observer': 3.4.0
|
||||
'@vexip-ui/utils': 2.16.4
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
'@vexip-ui/utils@2.16.4': {}
|
||||
|
||||
'@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
|
||||
dependencies:
|
||||
'@visactor/vchart': 2.0.4
|
||||
@@ -9179,6 +9217,13 @@ snapshots:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
|
||||
grid-layout-plus@1.1.1(vue@3.5.20(typescript@5.8.3)):
|
||||
dependencies:
|
||||
'@vexip-ui/hooks': 2.9.4(vue@3.5.20(typescript@5.8.3))
|
||||
'@vexip-ui/utils': 2.16.4
|
||||
interactjs: 1.10.27
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
gzip-size@6.0.0:
|
||||
dependencies:
|
||||
duplexer: 0.1.2
|
||||
@@ -9295,6 +9340,10 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
interactjs@1.10.27:
|
||||
dependencies:
|
||||
'@interactjs/types': 1.10.27
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
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>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
@@ -478,7 +478,7 @@ onBeforeUnmount(() => {
|
||||
{{ item.name }}
|
||||
</ElLink>
|
||||
<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)" />
|
||||
</li>
|
||||
|
||||
@@ -509,7 +509,7 @@ onBeforeUnmount(() => {
|
||||
</ElIcon>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
@@ -517,7 +517,7 @@ onBeforeUnmount(() => {
|
||||
{{ item.name }}
|
||||
</ElLink>
|
||||
<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)" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
|
||||
<template>
|
||||
<div class="business-rich-text-view">
|
||||
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { ElButton, ElPopover } from 'element-plus';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
icon?: Component;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
};
|
||||
@@ -17,12 +18,20 @@ export default defineComponent({
|
||||
actions: {
|
||||
type: Array as PropType<BusinessTableAction[]>,
|
||||
required: true
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<'button' | 'icon'>,
|
||||
default: 'button'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
const directActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return props.actions;
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return props.actions;
|
||||
}
|
||||
@@ -31,6 +40,10 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const moreActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,21 +60,86 @@ export default defineComponent({
|
||||
await action.onClick();
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action => (
|
||||
function renderIcon(action: BusinessTableAction) {
|
||||
if (!action.icon) return null;
|
||||
|
||||
return h(action.icon, { class: 'business-table-action-icon' });
|
||||
}
|
||||
|
||||
function renderButtonAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIconAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
class="business-table-action-icon-button"
|
||||
aria-label={action.label}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
{renderIcon(action)}
|
||||
</ElButton>
|
||||
))}
|
||||
</ElTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMenuButton(action: BusinessTableAction) {
|
||||
if (props.variant === 'icon') {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__link"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
<span class="business-table-action-menu__item">
|
||||
{renderIcon(action)}
|
||||
<span>{action.label}</span>
|
||||
</span>
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action =>
|
||||
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||
)}
|
||||
|
||||
{moreActions.value.length > 0 && (
|
||||
<ElPopover
|
||||
@@ -74,32 +152,28 @@ export default defineComponent({
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton
|
||||
plain
|
||||
link={props.variant === 'icon'}
|
||||
plain={props.variant !== 'icon'}
|
||||
size="small"
|
||||
class="business-table-action-button"
|
||||
class={
|
||||
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||
}
|
||||
aria-label={$t('common.more')}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
{props.variant === 'icon' ? (
|
||||
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
),
|
||||
default: () => (
|
||||
<div class="business-table-action-menu">
|
||||
{moreActions.value.map(action => (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
))}
|
||||
{moreActions.value.map(action => renderMenuButton(action))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
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">
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const ensuredEmptyDictCodes = new Set<string>();
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
placeholder?: string;
|
||||
@@ -14,6 +17,8 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||
showRemark?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
onlyEnabled: true,
|
||||
multiple: false,
|
||||
collapseTags: false,
|
||||
collapseTagsTooltip: false
|
||||
collapseTagsTooltip: false,
|
||||
showRemark: false
|
||||
});
|
||||
|
||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const dictStore = useDictStore();
|
||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||
|
||||
const dictOptions = computed(() => {
|
||||
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
||||
|
||||
return source.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
value: item.value,
|
||||
colorType: item.colorType ?? null,
|
||||
remark: item.remark ?? null
|
||||
}));
|
||||
});
|
||||
|
||||
// 单选时取当前选中项的 colorType,用于触发器 prefix 色块
|
||||
const selectedColorType = computed<string | null>(() => {
|
||||
if (props.multiple) return null;
|
||||
const value = model.value;
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||
async ([dictCode, optionCount, initialized, loading]) => {
|
||||
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensuredEmptyDictCodes.add(dictCode);
|
||||
await dictStore.ensureDictData(dictCode, true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="model"
|
||||
class="w-full"
|
||||
class="dict-select w-full"
|
||||
:placeholder="props.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:clearable="props.clearable"
|
||||
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
|
||||
:collapse-tags="props.collapseTags"
|
||||
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
||||
>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<template v-if="selectedColorType" #prefix>
|
||||
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
|
||||
</template>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||
<span class="dict-select__option">
|
||||
<span
|
||||
v-if="item.colorType"
|
||||
class="dict-select__color-dot dict-select__color-dot--option"
|
||||
:style="{ background: item.colorType }"
|
||||
/>
|
||||
<span class="dict-select__option-label">{{ item.label }}</span>
|
||||
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
|
||||
</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.dict-select__color-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dict-select__color-dot--option {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option-label {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dict-select__option-remark {
|
||||
margin-left: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictText from './dict-text.vue';
|
||||
|
||||
defineOptions({ name: 'DictTag' });
|
||||
@@ -14,6 +16,7 @@ interface Props {
|
||||
fallback?: string;
|
||||
separator?: string;
|
||||
onlyEnabled?: boolean;
|
||||
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
|
||||
type?: DictTagType;
|
||||
effect?: DictTagEffect;
|
||||
size?: DictTagSize;
|
||||
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
round: false
|
||||
});
|
||||
|
||||
const { getItem } = useDict(() => props.dictCode);
|
||||
|
||||
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
|
||||
const autoColorType = computed<string | null>(() => {
|
||||
if (Array.isArray(props.value)) return null;
|
||||
if (props.value === null || props.value === undefined || props.value === '') return null;
|
||||
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
|
||||
});
|
||||
|
||||
// props.type 优先(向后兼容);其次字典 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>
|
||||
|
||||
<template>
|
||||
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
|
||||
<ElTag
|
||||
:type="props.type"
|
||||
:effect="props.effect"
|
||||
:size="props.size"
|
||||
:round="props.round"
|
||||
:style="tagStyle ?? undefined"
|
||||
>
|
||||
<DictText
|
||||
:dict-code="props.dictCode"
|
||||
:value="props.value"
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface SearchField {
|
||||
options?: Option[];
|
||||
/** dict 类型的字典编码 */
|
||||
dictCode?: string;
|
||||
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急") */
|
||||
showRemark?: boolean;
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string;
|
||||
/** select 类型的自定义选项渲染函数 */
|
||||
@@ -179,6 +181,7 @@ function handleSearch() {
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
@@ -275,6 +278,7 @@ function handleSearch() {
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 需求优先级字典编码
|
||||
* 优先级字典编码
|
||||
*
|
||||
* 对应业务字段:需求相关接口和页面中的 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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 任务/个人事项类型字典编码
|
||||
*
|
||||
* 对应业务字段:任务、个人事项中的 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
|
||||
*/
|
||||
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,11 @@ export type StatusDomain =
|
||||
| 'taskAssigneeMember'
|
||||
| 'project'
|
||||
| 'product'
|
||||
| 'requirement'
|
||||
| 'workOrder';
|
||||
| 'productRequirement'
|
||||
| 'projectRequirement'
|
||||
| 'workOrder'
|
||||
| 'personalItem'
|
||||
| 'overtimeApplication';
|
||||
|
||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||
// 项目-执行
|
||||
@@ -50,10 +53,47 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
project: {},
|
||||
// 产品(待补全)
|
||||
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: {},
|
||||
// 个人事项
|
||||
personalItem: {
|
||||
pending: 'info',
|
||||
active: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'danger'
|
||||
},
|
||||
// 加班申请
|
||||
overtimeApplication: {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
cancelled: 'info'
|
||||
}
|
||||
};
|
||||
|
||||
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
||||
@@ -61,5 +101,9 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
|
||||
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',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store',
|
||||
ObjectContext = 'object-context-store'
|
||||
ObjectContext = 'object-context-store',
|
||||
Workbench = 'workbench-store'
|
||||
}
|
||||
|
||||
@@ -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,457 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'NotificationBell' });
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
||||
function buildMockNotifications(): NotificationItem[] {
|
||||
const titles = [
|
||||
'你被指派为执行「迭代 24.06」负责人',
|
||||
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
||||
'需求「多币种支持」评审通过',
|
||||
'工单 #1042 已分派给你',
|
||||
'需求「订单导出」被退回,请补充材料',
|
||||
'@ 你的评论已被回复',
|
||||
'项目「客户中心 2.0」周报已生成',
|
||||
'工单 #1098 客户回复待处理',
|
||||
'执行「迭代 24.05」已结束',
|
||||
'需求「批量审批」分配给你'
|
||||
];
|
||||
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
|
||||
return Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `m${i + 1}`,
|
||||
title: `${titles[i % titles.length]}(#${i + 1})`,
|
||||
timeLabel: times[Math.floor(i / 6) % times.length],
|
||||
unread: i < 14
|
||||
}));
|
||||
}
|
||||
|
||||
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
||||
|
||||
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
|
||||
const readAll = computed(() => notifications.value.filter(n => !n.unread));
|
||||
const unreadCount = computed(() => unreadAll.value.length);
|
||||
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const activeTab = ref<'unread' | 'read'>('unread');
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function matchesKeyword(item: NotificationItem) {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return true;
|
||||
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||
}
|
||||
|
||||
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||
|
||||
const unreadPageSize = ref(PAGE_SIZE);
|
||||
const readPageSize = ref(PAGE_SIZE);
|
||||
|
||||
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||
|
||||
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
||||
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
unreadPageSize.value = PAGE_SIZE;
|
||||
readPageSize.value = PAGE_SIZE;
|
||||
});
|
||||
|
||||
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||
|
||||
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => unreadScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => readScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function markRead(item: NotificationItem) {
|
||||
if (!item.unread) return;
|
||||
item.unread = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-read', item.id);
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
notifications.value.forEach(item => {
|
||||
item.unread = false;
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-all-read');
|
||||
}
|
||||
|
||||
function openItem(item: NotificationItem) {
|
||||
markRead(item);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] open', item.id);
|
||||
}
|
||||
|
||||
function onDrawerClosed() {
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="notification-bell__trigger"
|
||||
type="button"
|
||||
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
|
||||
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
|
||||
<div class="notification-bell__panel">
|
||||
<header class="notification-bell__header">
|
||||
<span class="notification-bell__title">
|
||||
通知
|
||||
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||
</span>
|
||||
<span class="notification-bell__header-actions">
|
||||
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
||||
<SvgIcon icon="mdi:close" />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="notification-bell__search">
|
||||
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" class="notification-bell__tabs">
|
||||
<ElTabPane name="unread">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
未读
|
||||
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
||||
<li
|
||||
v-for="row in visibleUnread"
|
||||
:key="row.id"
|
||||
class="notification-bell__row is-unread"
|
||||
@click="openItem(row)"
|
||||
>
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="read">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
已读
|
||||
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
||||
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-bell__trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__trigger:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.notification-bell__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.notification-bell__badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-bell__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__title-count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-bell__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__close:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__search {
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-bell__tab-count {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notification-bell__row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__row + .notification-bell__row {
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__row:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.notification-bell__row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-dot {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__row-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-bell__row-title {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-bell__row-time {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-bell__empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-bell__footer-hint {
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import NotificationBell from './components/notification-bell.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalHeader' });
|
||||
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
|
||||
@@ -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 { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
|
||||
defineOptions({
|
||||
@@ -108,7 +109,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
||||
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showObjectContextInfo && headerMenus.length"
|
||||
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-object-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-object-tag__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 14rem;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -168,24 +168,16 @@ const local: App.I18n.Schema = {
|
||||
metrics_worktime: 'Worktime',
|
||||
'personal-center': 'Personal Center',
|
||||
'personal-center_my-profile': 'My Profile',
|
||||
'personal-center_my-item': 'My Items',
|
||||
'personal-center_my-weekly': 'My Weekly Report',
|
||||
'personal-center_my-monthly': 'My Monthly Report',
|
||||
'personal-center_my-performance': 'My Performance',
|
||||
'personal-center_my-application': 'My Application',
|
||||
'personal-center_overtime-application': 'Overtime Application',
|
||||
'personal-center_pending-approval': 'Pending Approval',
|
||||
infra: 'Infra',
|
||||
'infra_state-machine': 'State Machine',
|
||||
'infra_rd-code': 'R&D Code',
|
||||
function: 'System Function',
|
||||
function_tab: 'Tab',
|
||||
'function_multi-tab': 'Multi Tab',
|
||||
'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_list: 'Product List',
|
||||
product_dashboard: 'Dashboard',
|
||||
@@ -209,28 +201,7 @@ const local: App.I18n.Schema = {
|
||||
exception: 'Exception',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
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'
|
||||
exception_500: '500'
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
@@ -326,45 +297,6 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
@@ -707,6 +639,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: 'Dictionary Status',
|
||||
dictLabel: 'Dictionary Label',
|
||||
dictValue: 'Dictionary Value',
|
||||
colorType: 'Color Type',
|
||||
sort: 'Sort',
|
||||
remark: 'Remark',
|
||||
form: {
|
||||
@@ -715,6 +648,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: 'Please select dictionary status',
|
||||
dictLabel: 'Please enter dictionary label',
|
||||
dictValue: 'Please enter dictionary value',
|
||||
colorType: 'Please enter color type',
|
||||
sort: 'Please enter sort',
|
||||
remark: 'Please enter remark'
|
||||
},
|
||||
|
||||
@@ -168,24 +168,16 @@ const local: App.I18n.Schema = {
|
||||
metrics_worktime: '工时统计',
|
||||
'personal-center': '个人中心',
|
||||
'personal-center_my-profile': '个人信息',
|
||||
'personal-center_my-item': '我的事项',
|
||||
'personal-center_my-weekly': '我的周报',
|
||||
'personal-center_my-monthly': '我的月报',
|
||||
'personal-center_my-performance': '我的绩效',
|
||||
'personal-center_my-application': '我的申请',
|
||||
'personal-center_overtime-application': '加班申请',
|
||||
'personal-center_pending-approval': '待我审批',
|
||||
infra: '基础设施',
|
||||
'infra_state-machine': '状态机管理',
|
||||
'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_list: '产品列表',
|
||||
product_dashboard: '产品仪表盘',
|
||||
@@ -209,28 +201,7 @@ const local: App.I18n.Schema = {
|
||||
exception: '异常页',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
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'
|
||||
exception_500: '500'
|
||||
},
|
||||
page: {
|
||||
login: {
|
||||
@@ -325,45 +296,6 @@ const local: App.I18n.Schema = {
|
||||
},
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
@@ -695,6 +627,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: '字典状态',
|
||||
dictLabel: '字典标签',
|
||||
dictValue: '字典键值',
|
||||
colorType: '颜色类型',
|
||||
sort: '排序',
|
||||
remark: '备注',
|
||||
form: {
|
||||
@@ -703,6 +636,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: '请选择字典状态',
|
||||
dictLabel: '请输入字典标签',
|
||||
dictValue: '请输入字典键值',
|
||||
colorType: '请输入颜色类型',
|
||||
sort: '请输入排序',
|
||||
remark: '请输入备注'
|
||||
},
|
||||
|
||||
@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
import 'uno.css';
|
||||
import '../styles/css/global.css';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
@@ -20,42 +20,19 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
500: () => import("@/views/_builtin/500/index.vue"),
|
||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].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_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
||||
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
||||
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
||||
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
|
||||
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
|
||||
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
|
||||
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
|
||||
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
|
||||
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
|
||||
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
|
||||
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
||||
plugin_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_list: () => import("@/views/product/list/index.vue"),
|
||||
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
||||
|
||||
@@ -39,124 +39,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/iframe-page/:url',
|
||||
@@ -291,6 +173,18 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-item',
|
||||
path: '/personal-center/my-item',
|
||||
component: 'view.personal-center_my-item',
|
||||
meta: {
|
||||
title: 'personal-center_my-item',
|
||||
i18nKey: 'route.personal-center_my-item',
|
||||
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-monthly',
|
||||
path: '/personal-center/my-monthly',
|
||||
@@ -339,6 +233,18 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_overtime-application',
|
||||
path: '/personal-center/overtime-application',
|
||||
component: 'view.personal-center_overtime-application',
|
||||
meta: {
|
||||
title: 'personal-center_overtime-application',
|
||||
i18nKey: 'route.personal-center_overtime-application',
|
||||
icon: 'mdi:clock-plus-outline',
|
||||
order: 6,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_pending-approval',
|
||||
path: '/personal-center/pending-approval',
|
||||
@@ -347,229 +253,12 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
title: 'personal-center_pending-approval',
|
||||
i18nKey: 'route.personal-center_pending-approval',
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 5,
|
||||
order: 7,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'plugin',
|
||||
path: '/plugin',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: '插件示例',
|
||||
i18nKey: 'route.plugin',
|
||||
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
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_gantt',
|
||||
path: '/plugin/gantt',
|
||||
meta: {
|
||||
title: 'plugin_gantt',
|
||||
i18nKey: 'route.plugin_gantt',
|
||||
icon: 'ant-design:bar-chart-outlined'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'plugin_gantt_dhtmlx',
|
||||
path: '/plugin/gantt/dhtmlx',
|
||||
component: 'view.plugin_gantt_dhtmlx',
|
||||
meta: {
|
||||
title: 'plugin_gantt_dhtmlx',
|
||||
i18nKey: 'route.plugin_gantt_dhtmlx',
|
||||
icon: 'gridicons:posts'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_gantt_vtable',
|
||||
path: '/plugin/gantt/vtable',
|
||||
component: 'view.plugin_gantt_vtable',
|
||||
meta: {
|
||||
title: 'plugin_gantt_vtable',
|
||||
i18nKey: 'route.plugin_gantt_vtable',
|
||||
localIcon: 'visactor'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
path: '/product',
|
||||
|
||||
@@ -170,16 +170,6 @@ const routeMap: RouteMap = {
|
||||
"403": "/403",
|
||||
"404": "/404",
|
||||
"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",
|
||||
"infra": "/infra",
|
||||
"infra_rd-code": "/infra/rd-code",
|
||||
@@ -191,32 +181,13 @@ const routeMap: RouteMap = {
|
||||
"metrics_worktime": "/metrics/worktime",
|
||||
"personal-center": "/personal-center",
|
||||
"personal-center_my-application": "/personal-center/my-application",
|
||||
"personal-center_my-item": "/personal-center/my-item",
|
||||
"personal-center_my-monthly": "/personal-center/my-monthly",
|
||||
"personal-center_my-performance": "/personal-center/my-performance",
|
||||
"personal-center_my-profile": "/personal-center/my-profile",
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly",
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application",
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||
"plugin": "/plugin",
|
||||
"plugin_barcode": "/plugin/barcode",
|
||||
"plugin_charts": "/plugin/charts",
|
||||
"plugin_charts_antv": "/plugin/charts/antv",
|
||||
"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_dashboard": "/product/dashboard",
|
||||
"product_list": "/product/list",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||
|
||||
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
||||
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
||||
@@ -15,6 +16,52 @@ function createBatchDeleteQuery(ids: number[]) {
|
||||
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) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
||||
@@ -60,20 +107,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取字典数据分页 */
|
||||
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
||||
export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||
const result = await request<DictDataPageResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/page`,
|
||||
method: 'get',
|
||||
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() {
|
||||
return request<Api.Dict.FrontendDictCache>({
|
||||
export async function fetchGetFrontendDictCache() {
|
||||
const result = await request<FrontendDictCacheResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
||||
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>({
|
||||
url: `${DICT_DATA_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
data: toSaveDictDataRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +157,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
|
||||
return request<boolean>({
|
||||
url: `${DICT_DATA_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
data: toSaveDictDataRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,9 +179,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 通过岗位编码获取该字典的所有字典数据 */
|
||||
export function fetchGetDictDataByCode(code: string) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
||||
export async function fetchGetDictDataByCode(code: string) {
|
||||
const result = await request<DictDataPageResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeDictData)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export * from './dict';
|
||||
export * from './file';
|
||||
export * from './infra';
|
||||
export * from './object-context';
|
||||
export * from './overtime-application';
|
||||
export * from './personal-item';
|
||||
export * from './product';
|
||||
export * from './project';
|
||||
export * from './project-shared';
|
||||
|
||||
293
src/service/api/overtime-application.ts
Normal file
293
src/service/api/overtime-application.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
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 OvertimeApplicationStatusLogResponse = Omit<
|
||||
Api.OvertimeApplication.OvertimeApplicationStatusLog,
|
||||
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
applicationId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
overtimeDateSnapshot: ProjectLocalDateValue;
|
||||
};
|
||||
|
||||
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 normalizeStatusLog(
|
||||
response: OvertimeApplicationStatusLogResponse
|
||||
): Api.OvertimeApplication.OvertimeApplicationStatusLog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
applicationId: normalizeStringId(response.applicationId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '',
|
||||
fromStatus: normalizeNullableStringId(response.fromStatus),
|
||||
reason: response.reason ?? null,
|
||||
remark: response.remark ?? 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 fetchCancelOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`,
|
||||
method: 'post',
|
||||
data: toStatusActionRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeleteOvertimeApplication(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGetOvertimeApplicationStatusLogs(id: string) {
|
||||
const result = await request<OvertimeApplicationStatusLogResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data =>
|
||||
data.map(normalizeStatusLog)
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
@@ -205,6 +205,41 @@ type RequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||
type RequirementReviewResponse = Omit<
|
||||
Api.Product.RequirementReview,
|
||||
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId: string | number;
|
||||
operatorId: string | number;
|
||||
attendees?: Array<{
|
||||
userId: string | number;
|
||||
nickname: string;
|
||||
}>;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
};
|
||||
type ProductRequirementDashboardSummaryResponse = {
|
||||
total?: number | string | null;
|
||||
todo?: number | string | null;
|
||||
pendingClaim?: number | string | null;
|
||||
pendingReview?: number | string | null;
|
||||
pendingDispatch?: number | string | null;
|
||||
completed?: number | string | null;
|
||||
completionRate?: number | string | null;
|
||||
highPriorityTodo?: number | string | null;
|
||||
};
|
||||
type ProductRequirementDashboardRecentChangeResponse = Omit<
|
||||
Api.Product.ProductRequirementDashboardRecentChange,
|
||||
'id' | 'requirementId' | 'operatorUserId'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId?: string | number | null;
|
||||
operatorUserId?: string | number | null;
|
||||
};
|
||||
type ProductRequirementDashboardResponse = {
|
||||
summary?: ProductRequirementDashboardSummaryResponse | null;
|
||||
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
|
||||
};
|
||||
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: string | number;
|
||||
@@ -242,6 +277,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) {
|
||||
const result = await request<RequirementPageResponse>({
|
||||
@@ -337,17 +417,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 关闭需求 */
|
||||
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/close`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取需求可执行的状态动作列表 */
|
||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||
@@ -379,16 +448,43 @@ export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Produ
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求生命周期信息 */
|
||||
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleInfo>({
|
||||
/** 提交产品需求评审 */
|
||||
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
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 +498,7 @@ export async function fetchGetRequirementStatusDict() {
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取需求终止态状态字典 */
|
||||
export async function fetchGetRequirementTerminalStatusDict() {
|
||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
||||
...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) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -423,7 +508,7 @@ export async function fetchHasDispatchedProjectRequirement(requirementId: string
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量判断产品需求是否已分流生成项目需求 */
|
||||
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||
...safeJsonRequestConfig,
|
||||
|
||||
@@ -23,6 +23,8 @@ export type ProjectExecutionResponse = Omit<
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'progressRate'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
@@ -34,6 +36,44 @@ export type ProjectExecutionResponse = Omit<
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
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 ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
||||
@@ -108,6 +148,8 @@ export type ProjectTaskResponse = Omit<
|
||||
| 'executionId'
|
||||
| 'parentTaskId'
|
||||
| 'ownerId'
|
||||
| 'executionOwnerId'
|
||||
| 'parentTaskOwnerId'
|
||||
| 'availableActions'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
@@ -116,12 +158,18 @@ export type ProjectTaskResponse = Omit<
|
||||
| 'progressRate'
|
||||
| 'assignees'
|
||||
| 'attachments'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
executionName?: string | null;
|
||||
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
|
||||
parentTaskId?: StringIdResponse | null;
|
||||
ownerId: StringIdResponse;
|
||||
executionOwnerId?: StringIdResponse | null;
|
||||
parentTaskOwnerId?: StringIdResponse | null;
|
||||
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||
plannedStartDate?: ProjectLocalDateValue;
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
@@ -131,13 +179,21 @@ export type ProjectTaskResponse = Omit<
|
||||
assignees?: TaskAssigneeRefResponse[] | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
totalSpentHours?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type TaskWorklogResponse = Omit<Api.Project.TaskWorklog, 'id' | 'taskId' | 'userId' | 'attachments'> & {
|
||||
export type TaskWorklogResponse = Omit<
|
||||
Api.Project.TaskWorklog,
|
||||
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
taskId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
difficulty?: string | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
startDate?: ProjectLocalDateValue;
|
||||
endDate?: ProjectLocalDateValue;
|
||||
};
|
||||
|
||||
export interface ProjectMemberResponse {
|
||||
@@ -233,12 +289,21 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePriority(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '1';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
statusName: response.statusName ?? null,
|
||||
@@ -250,11 +315,57 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
executionDesc: response.executionDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function 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 normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
|
||||
return {
|
||||
...response,
|
||||
@@ -289,9 +400,17 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
executionName: response.executionName ?? null,
|
||||
executionStatusCode: response.executionStatusCode ?? null,
|
||||
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
type: response.type ?? '',
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
|
||||
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
|
||||
statusName: response.statusName ?? null,
|
||||
terminal: Boolean(response.terminal),
|
||||
allowEdit: Boolean(response.allowEdit),
|
||||
@@ -301,6 +420,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
taskDesc: response.taskDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null,
|
||||
assignees:
|
||||
@@ -323,7 +444,13 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
|
||||
userNickname: response.userNickname ?? null,
|
||||
workContent: response.workContent ?? null,
|
||||
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,9 @@ import {
|
||||
import {
|
||||
type ExecutionAssigneeLogResponse,
|
||||
type ExecutionAssigneeResponse,
|
||||
type MyExecutionResponse,
|
||||
type MyOwnedProjectResponse,
|
||||
type MyParticipatedProjectResponse,
|
||||
type ProjectExecutionResponse,
|
||||
type ProjectLocalDateValue,
|
||||
type ProjectMemberResponse,
|
||||
@@ -20,6 +23,9 @@ import {
|
||||
getProjectLifecycleActions,
|
||||
normalizeExecutionAssignee,
|
||||
normalizeExecutionAssigneeLog,
|
||||
normalizeMyExecution,
|
||||
normalizeMyOwnedProject,
|
||||
normalizeMyParticipatedProject,
|
||||
normalizeProjectExecution,
|
||||
normalizeProjectLocalDate,
|
||||
normalizeProjectMember,
|
||||
@@ -365,6 +371,54 @@ 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 function fetchGetProjectExecutionStatusBoard(
|
||||
projectId: string,
|
||||
@@ -443,6 +497,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(
|
||||
projectId: string,
|
||||
@@ -638,6 +700,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(
|
||||
projectId: string,
|
||||
@@ -652,6 +722,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>;
|
||||
|
||||
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
|
||||
@@ -816,6 +960,19 @@ type ProjectRequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type ProjectRequirementPageResponse = Api.Project.PageResult<ProjectRequirementResponse>;
|
||||
type ProjectRequirementReviewResponse = Omit<
|
||||
Api.Project.ProjectRequirementReview,
|
||||
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId: string | number;
|
||||
operatorId: string | number;
|
||||
attendees?: Array<{
|
||||
userId: string | number;
|
||||
nickname: string;
|
||||
}>;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
};
|
||||
|
||||
type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
|
||||
id: string | number;
|
||||
@@ -855,10 +1012,27 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
|
||||
children: requirement.children?.map(normalizeProjectRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectRequirementReview(
|
||||
review: ProjectRequirementReviewResponse
|
||||
): Api.Project.ProjectRequirementReview {
|
||||
return {
|
||||
...review,
|
||||
id: normalizeStringId(review.id),
|
||||
requirementId: normalizeStringId(review.requirementId),
|
||||
operatorId: normalizeStringId(review.operatorId),
|
||||
attendees: review.attendees?.map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId)
|
||||
})),
|
||||
attachments: normalizeAttachments(review.attachments)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectRequirementModule(
|
||||
module: ProjectRequirementModuleResponse
|
||||
): Api.Project.ProjectRequirementModule {
|
||||
@@ -1013,16 +1187,31 @@ export async function fetchGetProjectRequirementAllowedTransitionsBatch(
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取项目需求生命周期信息 */
|
||||
export async function fetchGetProjectRequirementLifecycle(requirementId: string, projectId: string) {
|
||||
const result = await request<Api.Project.ProjectRequirementLifecycleInfo>({
|
||||
/** 提交项目需求评审 */
|
||||
export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, projectId }
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
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 +1225,6 @@ export async function fetchGetProjectRequirementStatusDict() {
|
||||
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) {
|
||||
const result = await request<ProjectRequirementModuleResponse[]>({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
|
||||
@@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||
export function fetchGetUserSimpleList() {
|
||||
export async function fetchGetUserSimpleList() {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/simple-list`,
|
||||
@@ -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) {
|
||||
return request<Api.SystemManage.UserList>({
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// 工作台聚合接口尚未开通,当前页面使用 src/views/workbench/mock.ts 的本地假数据。
|
||||
// 接口契约确认后,在此处补:
|
||||
// - fetchGetWorkbenchSummary (Banner 摘要 + KPI)
|
||||
// - fetchGetWorkbenchTodos (我的待办)
|
||||
// - fetchGetWorkbenchActivity (最近动态)
|
||||
// - fetchGetWorkbenchProjects (我参与的项目)
|
||||
// 全部走 src/service/request/index.ts 的统一实例,并保持 ID 字符串口径。
|
||||
export {};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
|
||||
type DictValue = string | number | null | undefined;
|
||||
@@ -19,6 +19,15 @@ function sortDictData(list: Api.Dict.DictData[]) {
|
||||
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
// hex 色值兜底校验:仅接受 #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(
|
||||
dictType: string,
|
||||
list: Api.Dict.FrontendDictData[],
|
||||
@@ -31,13 +40,25 @@ function normalizeFrontendDictData(
|
||||
dictType: item.dictType || dictType,
|
||||
sort: item.sort,
|
||||
status: item.status ?? 0,
|
||||
remark: null,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null,
|
||||
createTime: 0
|
||||
}));
|
||||
|
||||
return sortDictData(normalizedList);
|
||||
}
|
||||
|
||||
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||
return {
|
||||
...item,
|
||||
value: String(item.value),
|
||||
dictType: item.dictType || dictType,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||
const entries = Object.entries(cache);
|
||||
|
||||
@@ -89,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
const loadedAt = ref<number | null>(null);
|
||||
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
function resetDictCache() {
|
||||
dictTypes.value = [];
|
||||
@@ -96,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
loadedAt.value = null;
|
||||
initialized.value = false;
|
||||
initPromise = null;
|
||||
dictDataLoadPromises.clear();
|
||||
}
|
||||
|
||||
async function initDictCache(force = false) {
|
||||
@@ -137,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function ensureDictData(dictType: string, force = false) {
|
||||
if (!dictType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await initDictCache();
|
||||
}
|
||||
|
||||
if (!force && getDictData(dictType).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = dictDataLoadPromises.get(dictType);
|
||||
if (pending && !force) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchGetDictDataByCode(dictType);
|
||||
|
||||
if (result.error || !result.data?.list?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dictDataMap.value = {
|
||||
...dictDataMap.value,
|
||||
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||
};
|
||||
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
dictDataLoadPromises.set(dictType, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||
dictDataLoadPromises.delete(dictType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDictData(dictType: string, onlyEnabled = false) {
|
||||
if (!dictType) {
|
||||
return [];
|
||||
@@ -199,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
dictDataMap,
|
||||
loadedAt,
|
||||
initDictCache,
|
||||
ensureDictData,
|
||||
resetDictCache,
|
||||
getDictData,
|
||||
getDictOptions,
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -416,6 +416,20 @@ html .el-collapse {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.business-table-action-icon-button {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
&.el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.business-table-action-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.business-table-action-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -428,6 +442,19 @@ html .el-collapse {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.business-table-action-menu__link {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0 !important;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.business-table-action-menu__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.business-table-card-body {
|
||||
display: flex;
|
||||
height: calc(100% - 56px);
|
||||
|
||||
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;
|
||||
/** dict label */
|
||||
label: string;
|
||||
/** sign */
|
||||
sign?: string | null;
|
||||
/** dict value */
|
||||
value: string;
|
||||
/** dict type code */
|
||||
@@ -57,6 +55,8 @@ declare namespace Api {
|
||||
sort: number;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
status: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** remark */
|
||||
remark?: string | null;
|
||||
/** create time */
|
||||
@@ -67,8 +67,6 @@ declare namespace Api {
|
||||
interface FrontendDictData {
|
||||
/** dict label */
|
||||
label: string;
|
||||
/** sign */
|
||||
sign?: string | null;
|
||||
/** dict value */
|
||||
value: string;
|
||||
/** display order */
|
||||
@@ -77,6 +75,10 @@ declare namespace Api {
|
||||
dictType?: string;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
status?: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** 备注,可用于下拉中文释义展示 */
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
/** frontend runtime dict cache map */
|
||||
@@ -86,7 +88,7 @@ declare namespace Api {
|
||||
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||
|
||||
/** 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;
|
||||
};
|
||||
}
|
||||
|
||||
87
src/typings/api/overtime-application.d.ts
vendored
Normal file
87
src/typings/api/overtime-application.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
declare namespace Api {
|
||||
namespace OvertimeApplication {
|
||||
interface PageParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected' | 'cancelled';
|
||||
|
||||
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject' | 'cancel';
|
||||
|
||||
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 OvertimeApplicationStatusLog {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
actionType: OvertimeApplicationActionType;
|
||||
fromStatus?: string | null;
|
||||
toStatus: string;
|
||||
reason?: string | null;
|
||||
operatorUserId: string;
|
||||
operatorName: string;
|
||||
applicantNameSnapshot: string;
|
||||
overtimeDateSnapshot: string;
|
||||
overtimeDurationSnapshot: string;
|
||||
remark?: string | null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
105
src/typings/api/product.d.ts
vendored
105
src/typings/api/product.d.ts
vendored
@@ -256,15 +256,29 @@ declare namespace Api {
|
||||
// ========== 产品需求相关类型定义 ==========
|
||||
/** 需求状态编码 */
|
||||
type RequirementStatusCode =
|
||||
| 'pending_confirm'
|
||||
| 'pending_claim'
|
||||
| 'pending_review'
|
||||
| 'pending_dispatch'
|
||||
| 'reviewed'
|
||||
| 'review_rejected'
|
||||
| 'implementing'
|
||||
| 'accepted'
|
||||
| 'closed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
/** 需求状态动作编码 */
|
||||
type RequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_dispatch'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'dispatch'
|
||||
| 'cancel'
|
||||
| 'accept'
|
||||
| 'close'
|
||||
| 'reject';
|
||||
|
||||
/** 需求来源类型 */
|
||||
type RequirementSourceType = 'manual' | 'work_order';
|
||||
|
||||
@@ -333,8 +347,6 @@ declare namespace Api {
|
||||
updateTime: string;
|
||||
/** 子需求列表(树形结构) */
|
||||
children?: Requirement[];
|
||||
/** 是否为终态 */
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
// ========== 需求模块实体 ==========
|
||||
@@ -371,27 +383,18 @@ declare namespace Api {
|
||||
initialFlag: boolean;
|
||||
/** 是否终态 */
|
||||
terminalFlag: boolean;
|
||||
/** 是否允许编辑 */
|
||||
allowEdit: boolean;
|
||||
}
|
||||
|
||||
// ========== 需求生命周期 ==========
|
||||
|
||||
interface RequirementLifecycleAction {
|
||||
actionCode: string;
|
||||
actionCode: RequirementStatusActionCode;
|
||||
actionName: string;
|
||||
toStatusCode: string;
|
||||
toStatusName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface RequirementLifecycleInfo {
|
||||
statusCode: RequirementStatusCode;
|
||||
statusName?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
terminal: boolean;
|
||||
allowEdit: boolean;
|
||||
availableActions: RequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
interface RequirementBatchReqVO {
|
||||
productId: string;
|
||||
requirementIds: string[];
|
||||
@@ -407,6 +410,78 @@ declare namespace Api {
|
||||
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;
|
||||
}
|
||||
|
||||
// ========== 请求参数类型 ==========
|
||||
|
||||
/** 需求分页查询参数 */
|
||||
|
||||
335
src/typings/api/project.d.ts
vendored
335
src/typings/api/project.d.ts
vendored
@@ -65,7 +65,7 @@ declare namespace Api {
|
||||
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
|
||||
/** 执行动作编码 */
|
||||
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
|
||||
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel' | 'complete';
|
||||
|
||||
/** 任务状态编码 */
|
||||
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
@@ -96,6 +96,10 @@ declare namespace Api {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectRequirementId: string | null;
|
||||
/** 关联项目需求名称(service 层批量回填;未关联 = null) */
|
||||
projectRequirementName: string | null;
|
||||
/** 关联项目需求状态编码(pending_confirm/pending_review/implementing/accepted/closed/rejected/cancelled) */
|
||||
projectRequirementStatusCode: string | null;
|
||||
executionName: string;
|
||||
executionType: string | null;
|
||||
ownerId: string;
|
||||
@@ -110,6 +114,10 @@ declare namespace Api {
|
||||
actualStartDate: string | null;
|
||||
actualEndDate: string | null;
|
||||
progressRate: number;
|
||||
/** 优先级字典 value(rdms_req_priority):"0" P0 / "1" P1(默认)/ "2" P2 / "3" P3,数字越小越高 */
|
||||
priority: string;
|
||||
/** 优先级标签预留字段;当前后端不填、永远为 null,前端按 priority 自译 */
|
||||
priorityName: string | null;
|
||||
executionDesc: string | null;
|
||||
lastStatusReason: string | null;
|
||||
createTime: string;
|
||||
@@ -212,12 +220,23 @@ declare namespace Api {
|
||||
id: string;
|
||||
projectId: string;
|
||||
executionId: string;
|
||||
/** 所属执行名称;跨执行查询必有,单执行查询可缺省 */
|
||||
executionName?: string | null;
|
||||
/** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */
|
||||
executionStatusCode?: ProjectExecutionStatusCode | null;
|
||||
parentTaskId: string | null;
|
||||
/** 所属执行关联的项目需求 ID(透传,未关联 = null) */
|
||||
projectRequirementId: string | null;
|
||||
/** 所属执行关联的项目需求名称(透传,未关联 = null;跨执行查询永远为 null,前端不在跨执行视角展示) */
|
||||
projectRequirementName: string | null;
|
||||
/** 所属执行关联的项目需求状态编码(同上) */
|
||||
projectRequirementStatusCode: string | null;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string;
|
||||
ownerNickname?: string | null;
|
||||
/** 所属执行的负责人 userId(按钮可见度公式用) */
|
||||
executionOwnerId: string;
|
||||
/** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */
|
||||
executionOwnerId: string | null;
|
||||
/** 父任务负责人 userId(一级任务为 null) */
|
||||
parentTaskOwnerId: string | null;
|
||||
statusCode: ProjectTaskStatusCode;
|
||||
@@ -230,6 +249,10 @@ declare namespace Api {
|
||||
plannedEndDate: string | null;
|
||||
actualStartDate: 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;
|
||||
lastStatusReason: string | null;
|
||||
assignees?: TaskAssigneeRef[] | null;
|
||||
@@ -240,12 +263,31 @@ declare namespace Api {
|
||||
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<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
keyword: string;
|
||||
executionType: string;
|
||||
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||
involveUserId: string;
|
||||
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
dueRange: ProjectExecutionDueRange;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -253,10 +295,116 @@ declare namespace Api {
|
||||
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
|
||||
keyword: string;
|
||||
executionType: string;
|
||||
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||
involveUserId: string;
|
||||
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||
ownerId: string;
|
||||
/** 截止时间范围过滤,传入后各状态分组计数均在该范围内统计(口径同 page) */
|
||||
dueRange: ProjectExecutionDueRange;
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 创建执行入参(含 ownerId + assigneeUserIds) */
|
||||
interface CreateProjectExecutionParams {
|
||||
executionName: string;
|
||||
@@ -265,6 +413,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
assigneeUserIds?: string[];
|
||||
}
|
||||
@@ -279,6 +429,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
}
|
||||
|
||||
@@ -306,6 +458,8 @@ declare namespace Api {
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -330,6 +484,8 @@ declare namespace Api {
|
||||
keyword: string;
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -347,13 +503,93 @@ declare namespace Api {
|
||||
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 {
|
||||
parentTaskId: string | null;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string | null;
|
||||
progressRate?: number;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
taskDesc: string | null;
|
||||
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行协办人且不能等于 ownerId */
|
||||
assigneeUserIds?: string[];
|
||||
@@ -380,6 +616,10 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2) */
|
||||
progressRate: number;
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
/** 后端预留字段,目前始终为 null,前端按 difficulty + 字典 cache 自译 */
|
||||
difficultyName?: string | null;
|
||||
workContent: string | null;
|
||||
attachments?: AttachmentItem[] | null;
|
||||
createTime: string;
|
||||
@@ -391,6 +631,8 @@ declare namespace Api {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** 完成难度筛选,等值匹配;不传 = 全部 */
|
||||
difficulty: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -403,6 +645,8 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2,必填) */
|
||||
progressRate: number;
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
workContent?: string | null;
|
||||
/** 编辑语义:null 保留原值 / [] 清空 / [...] 替换 */
|
||||
attachments?: AttachmentItem[] | null;
|
||||
@@ -597,6 +841,24 @@ declare namespace Api {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 执行删除预检(spec §2.1:判断是否需要走重型确认弹层) */
|
||||
interface ProjectExecutionDeletePrecheck {
|
||||
/** 该执行下任务总数(含子孙,含 completed),展示用 */
|
||||
taskCount: number;
|
||||
/** taskCount > 0 视为 true */
|
||||
hasDependentData: boolean;
|
||||
}
|
||||
|
||||
/** 任务删除预检(spec §2.1) */
|
||||
interface ProjectTaskDeletePrecheck {
|
||||
/** 直接子任务数 */
|
||||
childTaskCount: number;
|
||||
/** 工作日志条数 */
|
||||
worklogCount: number;
|
||||
/** childTaskCount + worklogCount > 0 视为 true */
|
||||
hasDependentData: boolean;
|
||||
}
|
||||
|
||||
/** 创建项目成员参数 */
|
||||
interface CreateProjectMemberParams {
|
||||
userId: string;
|
||||
@@ -655,14 +917,28 @@ declare namespace Api {
|
||||
// ========== 项目需求相关类型定义 ==========
|
||||
/** 项目需求状态编码 */
|
||||
type ProjectRequirementStatusCode =
|
||||
| 'pending_confirm'
|
||||
| 'pending_claim'
|
||||
| 'pending_review'
|
||||
| 'reviewed'
|
||||
| 'review_rejected'
|
||||
| 'implementing'
|
||||
| 'accepted'
|
||||
| 'closed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
/** 项目需求状态动作编码 */
|
||||
type ProjectRequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_implement'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'start_implement'
|
||||
| 'accept'
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'reject';
|
||||
|
||||
/** 项目需求来源类型 */
|
||||
type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
|
||||
|
||||
@@ -719,14 +995,14 @@ declare namespace Api {
|
||||
expectedTime?: string | null;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 项目需求进度(BigDecimal,0.00 ~ 1.00;HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
|
||||
progressRate: number;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 子需求列表 */
|
||||
children?: ProjectRequirement[];
|
||||
/** 是否终态 */
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementModule {
|
||||
@@ -759,25 +1035,18 @@ declare namespace Api {
|
||||
initialFlag: boolean;
|
||||
/** 是否终态 */
|
||||
terminalFlag: boolean;
|
||||
/** 是否允许编辑 */
|
||||
allowEdit: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleAction {
|
||||
actionCode: string;
|
||||
actionCode: ProjectRequirementStatusActionCode;
|
||||
actionName: string;
|
||||
toStatusCode: string;
|
||||
toStatusName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleInfo {
|
||||
statusCode: ProjectRequirementStatusCode;
|
||||
statusName?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
terminal: boolean;
|
||||
allowEdit: boolean;
|
||||
availableActions: ProjectRequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
interface ProjectRequirementBatchReqVO {
|
||||
projectId: string;
|
||||
requirementIds: string[];
|
||||
@@ -788,6 +1057,40 @@ declare namespace Api {
|
||||
transitions: ProjectRequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
type ProjectRequirementReviewConclusion = 0 | 1;
|
||||
|
||||
interface ProjectRequirementReviewAttendeeItem {
|
||||
userId: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface ProjectRequirementReview {
|
||||
id: string;
|
||||
objectType: 'project_requirement';
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: ProjectRequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: ProjectRequirementReviewAttendeeItem[];
|
||||
attachments?: AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
interface ProjectRequirementReviewSubmitParams {
|
||||
projectId: string;
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: ProjectRequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: ProjectRequirementReviewAttendeeItem[];
|
||||
attachments?: AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
}
|
||||
|
||||
/** 项目需求分页查询参数 */
|
||||
type ProjectRequirementSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
|
||||
41
src/typings/app.d.ts
vendored
41
src/typings/app.d.ts
vendored
@@ -504,45 +504,6 @@ declare namespace App {
|
||||
};
|
||||
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: {
|
||||
common: {
|
||||
status: {
|
||||
@@ -866,6 +827,7 @@ declare namespace App {
|
||||
dictStatus: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
colorType: string;
|
||||
sort: string;
|
||||
remark: string;
|
||||
form: {
|
||||
@@ -874,6 +836,7 @@ declare namespace App {
|
||||
dictStatus: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
colorType: string;
|
||||
sort: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
9
src/typings/components.d.ts
vendored
9
src/typings/components.d.ts
vendored
@@ -9,6 +9,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||
AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.vue')['default']
|
||||
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
|
||||
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
||||
@@ -18,6 +19,7 @@ declare module 'vue' {
|
||||
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
||||
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
||||
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
@@ -100,10 +102,14 @@ declare module 'vue' {
|
||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||
'IconEp:check': typeof import('~icons/ep/check')['default']
|
||||
'IconEp:files': typeof import('~icons/ep/files')['default']
|
||||
'IconEp:folder': typeof import('~icons/ep/folder')['default']
|
||||
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
|
||||
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
||||
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||
@@ -144,6 +150,7 @@ declare module 'vue' {
|
||||
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||
IconMdiDownload: typeof import('~icons/mdi/download')['default']
|
||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
|
||||
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
||||
@@ -151,6 +158,7 @@ declare module 'vue' {
|
||||
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
|
||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
|
||||
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
||||
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||
@@ -175,6 +183,7 @@ declare module 'vue' {
|
||||
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
|
||||
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
|
||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
||||
}
|
||||
|
||||
62
src/typings/elegant-router.d.ts
vendored
62
src/typings/elegant-router.d.ts
vendored
@@ -24,16 +24,6 @@ declare module "@elegant-router/types" {
|
||||
"403": "/403";
|
||||
"404": "/404";
|
||||
"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";
|
||||
"infra": "/infra";
|
||||
"infra_rd-code": "/infra/rd-code";
|
||||
@@ -45,32 +35,13 @@ declare module "@elegant-router/types" {
|
||||
"metrics_worktime": "/metrics/worktime";
|
||||
"personal-center": "/personal-center";
|
||||
"personal-center_my-application": "/personal-center/my-application";
|
||||
"personal-center_my-item": "/personal-center/my-item";
|
||||
"personal-center_my-monthly": "/personal-center/my-monthly";
|
||||
"personal-center_my-performance": "/personal-center/my-performance";
|
||||
"personal-center_my-profile": "/personal-center/my-profile";
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly";
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application";
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval";
|
||||
"plugin": "/plugin";
|
||||
"plugin_barcode": "/plugin/barcode";
|
||||
"plugin_charts": "/plugin/charts";
|
||||
"plugin_charts_antv": "/plugin/charts/antv";
|
||||
"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_dashboard": "/product/dashboard";
|
||||
"product_list": "/product/list";
|
||||
@@ -133,13 +104,11 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "function"
|
||||
| "iframe-page"
|
||||
| "infra"
|
||||
| "login"
|
||||
| "metrics"
|
||||
| "personal-center"
|
||||
| "plugin"
|
||||
| "product"
|
||||
| "project"
|
||||
| "system"
|
||||
@@ -167,42 +136,19 @@ declare module "@elegant-router/types" {
|
||||
| "500"
|
||||
| "iframe-page"
|
||||
| "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_state-machine"
|
||||
| "metrics_member-efficiency"
|
||||
| "metrics_project-progress"
|
||||
| "metrics_worktime"
|
||||
| "personal-center_my-application"
|
||||
| "personal-center_my-item"
|
||||
| "personal-center_my-monthly"
|
||||
| "personal-center_my-performance"
|
||||
| "personal-center_my-profile"
|
||||
| "personal-center_my-weekly"
|
||||
| "personal-center_overtime-application"
|
||||
| "personal-center_pending-approval"
|
||||
| "plugin_barcode"
|
||||
| "plugin_charts_antv"
|
||||
| "plugin_charts_echarts"
|
||||
| "plugin_charts_vchart"
|
||||
| "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_list"
|
||||
| "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';
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 StateTransitionDialog from './modules/state-transition-dialog.vue';
|
||||
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
|
||||
defineOptions({ name: 'StateMachineManage' });
|
||||
|
||||
@@ -71,6 +74,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'transition',
|
||||
label: '状态流转',
|
||||
icon: IconMdiSourceBranch,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openTransitionDialog(row)
|
||||
});
|
||||
@@ -80,6 +84,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: IconMdiPencilOutline,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
@@ -89,6 +94,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: IconMdiDeleteOutline,
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
});
|
||||
@@ -203,7 +209,7 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
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>
|
||||
401
src/views/personal-center/overtime-application/index.vue
Normal file
401
src/views/personal-center/overtime-application/index.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchCancelOvertimeApplication,
|
||||
fetchDeleteOvertimeApplication,
|
||||
fetchExportOvertimeApplications,
|
||||
fetchGetOvertimeApplicationPage
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import OvertimeApplicationActionDialog from './modules/overtime-application-action-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 OvertimeApplicationStatusLogDialog from './modules/overtime-application-status-log-dialog.vue';
|
||||
import {
|
||||
downloadBlob,
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './modules/overtime-application-shared';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiHistory from '~icons/mdi/history';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplication' });
|
||||
|
||||
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
|
||||
type ActionType = 'cancel';
|
||||
|
||||
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 statusLogVisible = ref(false);
|
||||
const actionVisible = ref(false);
|
||||
const operateType = ref<'add' | 'edit'>('add');
|
||||
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const currentActionType = ref<ActionType>('cancel');
|
||||
const actionSubmitting = ref(false);
|
||||
const exporting = ref(false);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
statusLog: markRaw(IconMdiHistory),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
cancel: markRaw(IconMdiCloseCircleOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline)
|
||||
};
|
||||
|
||||
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,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => formatEmptyText(row.overtimeReason)
|
||||
},
|
||||
{
|
||||
prop: 'overtimeContent',
|
||||
label: '加班内容',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
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: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'submitTime',
|
||||
label: '提交时间',
|
||||
minWidth: 170,
|
||||
formatter: row => formatOvertimeDateTime(row.submitTime)
|
||||
},
|
||||
{
|
||||
prop: 'approvalTime',
|
||||
label: '审核时间',
|
||||
minWidth: 170,
|
||||
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.statusCode === 'cancelled') && row.allowEdit) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '修改',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'status-log',
|
||||
label: '状态日志',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.statusLog,
|
||||
onClick: () => openStatusLog(row)
|
||||
});
|
||||
|
||||
if (row.statusCode === 'pending') {
|
||||
actions.push({
|
||||
key: 'cancel',
|
||||
label: '撤销',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.cancel,
|
||||
onClick: () => openCancel(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (row.statusCode === 'cancelled') {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.delete,
|
||||
onClick: () => handleDelete(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 openStatusLog(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
currentRow.value = row;
|
||||
statusLogVisible.value = true;
|
||||
}
|
||||
|
||||
function openCancel(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
currentRow.value = row;
|
||||
currentActionType.value = 'cancel';
|
||||
actionVisible.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 handleActionSubmit(reason: string | null) {
|
||||
if (!currentRow.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionSubmitting.value = true;
|
||||
const { error } = await fetchCancelOvertimeApplication(currentRow.value.id, { reason });
|
||||
actionSubmitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionVisible.value = false;
|
||||
window.$message?.success('加班申请已撤销');
|
||||
await reloadTable(searchParams.pageNo ?? 1);
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 ${row.applicantName} ${formatOvertimeDate(row.overtimeDate)} 的加班申请吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteOvertimeApplication(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('加班申请已删除');
|
||||
await 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" />
|
||||
|
||||
<OvertimeApplicationStatusLogDialog v-model:visible="statusLogVisible" :row-data="currentRow" />
|
||||
|
||||
<OvertimeApplicationActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:action-type="currentActionType"
|
||||
:loading="actionSubmitting"
|
||||
@submit="handleActionSubmit"
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<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' | 'cancel';
|
||||
|
||||
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: '退回加班申请',
|
||||
cancel: '撤销加班申请'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonLabel = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '审核意见',
|
||||
reject: '退回原因',
|
||||
cancel: '撤销原因'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonRequired = computed(() => props.actionType === 'reject');
|
||||
|
||||
const reasonPlaceholder = computed(() => {
|
||||
if (reasonRequired.value) {
|
||||
return `请输入${reasonLabel.value}`;
|
||||
}
|
||||
|
||||
return props.actionType === 'cancel' ? '可填写撤销原因' : '可填写审核意见';
|
||||
});
|
||||
|
||||
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,97 @@
|
||||
<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 {
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './overtime-application-shared';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
|
||||
const statusTagType = computed(() => resolveOvertimeApplicationStatusTagType(detailData.value?.statusCode));
|
||||
const statusLabel = computed(() =>
|
||||
getOvertimeApplicationStatusLabel(detailData.value?.statusCode, detailData.value?.statusName)
|
||||
);
|
||||
|
||||
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="false">
|
||||
<ElDescriptions v-if="detailData" :column="2" border>
|
||||
<ElDescriptionsItem label="状态">
|
||||
<ElTag :type="statusTagType">{{ statusLabel }}</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="申请人">
|
||||
{{ detailData.applicantName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班日期">{{ formatOvertimeDate(detailData.overtimeDate) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班时长">{{ detailData.overtimeDuration }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核人">
|
||||
{{ detailData.approverName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="提交时间">{{ formatOvertimeDateTime(detailData.submitTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核时间">{{ formatOvertimeDateTime(detailData.approvalTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核意见">{{ formatEmptyText(detailData.approvalComment) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班原因" :span="2">{{ detailData.overtimeReason }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班内容" :span="2">{{ detailData.overtimeContent }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
: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,273 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
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: '',
|
||||
overtimeDuration: '',
|
||||
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>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationStatusDict } from '@/service/api';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.OvertimeApplication.OvertimeApplicationSearchParams>('model', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const searchModel = reactive<Record<string, any>>({
|
||||
applicantName: '',
|
||||
overtimeDate: undefined,
|
||||
statusCode: undefined,
|
||||
approverName: ''
|
||||
});
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
|
||||
let syncingFromSource = false;
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[model.value.applicantName, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
syncingFromSource = true;
|
||||
searchModel.applicantName = applicantName ?? '';
|
||||
searchModel.overtimeDate = overtimeDate;
|
||||
searchModel.statusCode = statusCode;
|
||||
searchModel.approverName = approverName ?? '';
|
||||
syncingFromSource = false;
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
);
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[searchModel.applicantName, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
if (syncingFromSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.applicantName = applicantName?.trim() || undefined;
|
||||
model.value.overtimeDate = overtimeDate;
|
||||
model.value.statusCode = statusCode;
|
||||
model.value.approverName = approverName?.trim() || undefined;
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetOvertimeApplicationStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
statusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
statusOptions.value = data.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStatusOptions();
|
||||
});
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'applicantName',
|
||||
label: '申请人',
|
||||
type: 'input',
|
||||
placeholder: '请输入申请人'
|
||||
},
|
||||
{
|
||||
key: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择加班日期'
|
||||
},
|
||||
{
|
||||
key: 'statusCode',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: statusOptions.value,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
{
|
||||
key: 'approverName',
|
||||
label: '审核人',
|
||||
type: 'input',
|
||||
placeholder: '请输入审核人'
|
||||
}
|
||||
]);
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="handleReset" @search="handleSearch" />
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
export const overtimeApplicationStatusOptions: Array<{
|
||||
label: string;
|
||||
value: Api.OvertimeApplication.OvertimeApplicationStatusCode;
|
||||
}> = [
|
||||
{ label: '待审批', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已退回', value: 'rejected' },
|
||||
{ label: '已撤销', value: 'cancelled' }
|
||||
];
|
||||
|
||||
export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = {
|
||||
submit: '提交',
|
||||
resubmit: '重新提交',
|
||||
approve: '通过',
|
||||
reject: '退回',
|
||||
cancel: '撤销'
|
||||
};
|
||||
|
||||
export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) {
|
||||
if (statusName) {
|
||||
return statusName;
|
||||
}
|
||||
|
||||
return overtimeApplicationStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
|
||||
}
|
||||
|
||||
export function resolveOvertimeApplicationStatusTagType(statusCode?: string | null) {
|
||||
return getStatusTagType('overtimeApplication', statusCode);
|
||||
}
|
||||
|
||||
export function getOvertimeApplicationActionLabel(actionType?: string | null) {
|
||||
if (!actionType) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return (
|
||||
overtimeApplicationActionNameMap[actionType as Api.OvertimeApplication.OvertimeApplicationActionType] || actionType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatOvertimeDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
|
||||
export function formatOvertimeDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
|
||||
}
|
||||
|
||||
export function formatEmptyText(value?: string | null) {
|
||||
return value?.trim() || '--';
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="tsx">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { fetchGetOvertimeApplicationStatusLogs } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationActionLabel,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './overtime-application-shared';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationStatusLogDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<Api.OvertimeApplication.OvertimeApplicationStatusLog[]>([]);
|
||||
|
||||
async function loadLogs() {
|
||||
if (!props.rowData?.id) {
|
||||
logs.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetOvertimeApplicationStatusLogs(props.rowData.id);
|
||||
loading.value = false;
|
||||
|
||||
logs.value = error || !data ? [] : data;
|
||||
}
|
||||
|
||||
function renderStatus(code?: string | null) {
|
||||
if (!code) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return <ElTag type={resolveOvertimeApplicationStatusTagType(code)}>{getOvertimeApplicationStatusLabel(code)}</ElTag>;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="状态日志"
|
||||
width="920px"
|
||||
:loading="loading"
|
||||
:show-footer="false"
|
||||
max-body-height="72vh"
|
||||
>
|
||||
<ElTable border :data="logs">
|
||||
<ElTableColumn prop="createTime" label="操作时间" width="170">
|
||||
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="actionType" label="动作" width="110">
|
||||
<template #default="{ row }">{{ getOvertimeApplicationActionLabel(row.actionType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="operatorName" label="操作人" width="120" show-overflow-tooltip />
|
||||
<ElTableColumn prop="fromStatus" label="原状态" width="110" :formatter="row => renderStatus(row.fromStatus)" />
|
||||
<ElTableColumn prop="toStatus" label="新状态" width="110" :formatter="row => renderStatus(row.toStatus)" />
|
||||
<ElTableColumn prop="reason" label="原因/意见" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatEmptyText(row.reason) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="overtimeDateSnapshot" label="加班日期" width="120">
|
||||
<template #default="{ row }">{{ formatOvertimeDate(row.overtimeDateSnapshot) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="overtimeDurationSnapshot" label="时长" width="90" show-overflow-tooltip />
|
||||
</ElTable>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import type { Options } from 'jsbarcode';
|
||||
|
||||
defineOptions({ name: 'BarcodePage' });
|
||||
|
||||
const text = 'CN-RDMS';
|
||||
|
||||
interface CodeConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
options: Options;
|
||||
}
|
||||
|
||||
const codes: CodeConfig[] = [
|
||||
{
|
||||
id: 'code39',
|
||||
title: 'CODE 39 正常尺寸',
|
||||
text: 'Hello',
|
||||
options: { format: 'code39' }
|
||||
},
|
||||
{
|
||||
id: 'code128',
|
||||
title: 'CODE 128 正常尺寸',
|
||||
text,
|
||||
options: {}
|
||||
},
|
||||
{
|
||||
id: 'ean-13',
|
||||
title: 'ENA-13 商品条形码',
|
||||
text: '1234567890128',
|
||||
options: { format: 'ean13' }
|
||||
},
|
||||
{
|
||||
id: 'upc-a',
|
||||
title: 'UPC-A 商品条形码',
|
||||
text: '123456789012',
|
||||
options: { format: 'upc' }
|
||||
},
|
||||
{
|
||||
id: 'barcode',
|
||||
title: '不一样的高度,不一样的颜色',
|
||||
text: 'Hello',
|
||||
options: {
|
||||
height: 30,
|
||||
lineColor: '#9ca3af'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode1',
|
||||
title: '加个背景色',
|
||||
text,
|
||||
options: {
|
||||
background: '#9ca3af',
|
||||
lineColor: '#ffffff'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode2',
|
||||
title: '字体好大',
|
||||
text,
|
||||
options: {
|
||||
fontSize: 40
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode3',
|
||||
title: '粗狂的条码,文字离远点',
|
||||
text: 'Hi',
|
||||
options: {
|
||||
textMargin: 30,
|
||||
width: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode4',
|
||||
title: '字体跑上面来,还是粗体',
|
||||
text,
|
||||
options: {
|
||||
textPosition: 'top',
|
||||
fontOptions: 'bold'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function generateBarcode() {
|
||||
codes.forEach(code => {
|
||||
JsBarcode(`#${code.id}`, code.text, code.options);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateBarcode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<ElCard header="条形码" class="h-full card-wrapper">
|
||||
<ElScrollbar class="h-full">
|
||||
<ElRow :gutter="12" class="w-[calc(100%-12px)]">
|
||||
<ElCol v-for="item in codes" :key="item.id" :lg="8" :md="12" :sm="24" class="mb-24px">
|
||||
<div class="flex-col-center">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<svg :id="item.id" class="h-130px" />
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { CustomGraphData } from './modules/types';
|
||||
|
||||
// 日期可以自己随便设置,就是字符串展示,也可以修改为业务需要的字段
|
||||
export function getFlowData(): CustomGraphData {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: 'NS',
|
||||
name: 'Start',
|
||||
status: 'COMPLETED',
|
||||
startDate: '2024-10-01',
|
||||
endDate: '2024-10-07',
|
||||
actualStartDate: '2024-10-01',
|
||||
actualEndDate: '2024-10-07'
|
||||
},
|
||||
{
|
||||
id: 'N1',
|
||||
name: 'Node1',
|
||||
status: 'COMPLETED_EARLY',
|
||||
startDate: '2024-10-08',
|
||||
endDate: '2024-10-10',
|
||||
actualStartDate: '2024-10-08',
|
||||
actualEndDate: '2024-10-09',
|
||||
milestone: true
|
||||
},
|
||||
{
|
||||
id: 'N2',
|
||||
name: 'Node2',
|
||||
status: 'COMPLETED_EARLY',
|
||||
startDate: '2024-10-11',
|
||||
endDate: '2024-10-13',
|
||||
actualStartDate: '2024-10-11',
|
||||
actualEndDate: '2024-10-12'
|
||||
},
|
||||
{ id: 'N3', name: 'Node3', status: 'IN_PROGRESS', isDeleted: true },
|
||||
{ id: 'N4', name: 'Node4', status: 'COMPLETED_LATE' },
|
||||
{ id: 'N5', name: 'Node5', status: 'DELAYED', isDelayed: true, milestone: true },
|
||||
{ id: 'N6', name: 'Node6', status: 'PAUSED' },
|
||||
{ id: 'N7', name: 'Node7', status: 'NOT_STARTED' },
|
||||
{ id: 'N8', name: 'Node8', status: 'NOT_STARTED' },
|
||||
{ id: 'N9', name: 'End', status: 'NOT_STARTED' },
|
||||
{ id: 'NX', name: 'NodeX', status: 'NOT_STARTED', isDeleted: true }
|
||||
],
|
||||
edges: [
|
||||
{ id: 'E1', source: 'NS', target: 'N1' },
|
||||
{ id: 'E2', source: 'N1', target: 'N2' },
|
||||
{ id: 'E3', source: 'N1', target: 'N3', isDeleted: true },
|
||||
{ id: 'E4', source: 'N1', target: 'N4' },
|
||||
{ id: 'E5', source: 'N2', target: 'N5' },
|
||||
{ id: 'E6', source: 'N3', target: 'N5', isDeleted: true },
|
||||
{ id: 'E7', source: 'N4', target: 'N5' },
|
||||
{ id: 'E8', source: 'N5', target: 'N6' },
|
||||
{ id: 'E9', source: 'N6', target: 'N7' },
|
||||
{ id: 'E10', source: 'N6', target: 'N8' },
|
||||
{ id: 'E11', source: 'N7', target: 'N9' },
|
||||
{ id: 'EX', source: 'N8', target: 'N9' },
|
||||
{ id: 'EO', source: 'N5', target: 'NX', isDeleted: true }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { CustomBehaviorOption, IPointerEvent } from '@antv/g6';
|
||||
import AntvFlow from './modules/antv-flow.vue';
|
||||
import type { CustomGraphData } from './modules/types';
|
||||
import { getFlowData } from './data';
|
||||
|
||||
defineOptions({ name: 'AntVCharts' });
|
||||
|
||||
const antvFlowRef = useTemplateRef('antvFlowRef');
|
||||
|
||||
const flowData = ref({
|
||||
nodes: [],
|
||||
edges: []
|
||||
}) as Ref<CustomGraphData>;
|
||||
|
||||
const selectedNode = ref<string | undefined>('N2');
|
||||
|
||||
const behaviors: CustomBehaviorOption[] = [
|
||||
{
|
||||
type: 'click-select',
|
||||
enable: (event: IPointerEvent) => event.targetType === 'node',
|
||||
onClick: (event: IPointerEvent) => {
|
||||
const node = event.target as unknown as HTMLElement;
|
||||
const nodeData = flowData.value.nodes.find(item => item.id === node.id);
|
||||
selectedNode.value = nodeData?.id;
|
||||
window.$message?.success(`选中节点:[${node.id}]${nodeData?.name}`);
|
||||
}
|
||||
}
|
||||
];
|
||||
const hasNodeN = computed(() => flowData.value.nodes.some(node => node.id === 'NN'));
|
||||
|
||||
function addNode() {
|
||||
const { nodes, edges } = flowData.value;
|
||||
|
||||
nodes.push({ id: 'NN', name: 'New node', status: 'NOT_STARTED' });
|
||||
edges.push({ id: 'EN', source: 'N5', target: 'NN' });
|
||||
flowData.value = { nodes, edges };
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
const { nodes, edges } = flowData.value;
|
||||
// 删除node的同时,也需要删除包含NX的edge
|
||||
flowData.value = {
|
||||
nodes: nodes.filter(node => node.id !== id),
|
||||
edges: edges.filter(edge => edge.source !== id && edge.target !== id)
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
flowData.value = getFlowData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="AntV G6 Next" class="h-full card-wrapper">
|
||||
<AntvFlow ref="antvFlowRef" :data="flowData" :selected="selectedNode" :behaviors="behaviors" />
|
||||
<ElDivider />
|
||||
<ElButton @click="selectedNode = 'N5'">选中节点N5(需要自行处理选中事件,不会触发元素点击)</ElButton>
|
||||
<ElButton v-if="!hasNodeN" @click="addNode">添加节点并与Node5连线</ElButton>
|
||||
<ElButton v-else @click="() => removeNode('NN')">删除新添加的节点</ElButton>
|
||||
<ElButton @click="() => removeNode('NX')">删除NodeX</ElButton>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import type { CustomBehaviorOption, Graph } from '@antv/g6';
|
||||
import { useAntFlow } from './antv-g6-flow';
|
||||
import { nodeStatus } from './status';
|
||||
import type { CustomGraphData } from './types';
|
||||
|
||||
defineOptions({ name: 'AntvFLow' });
|
||||
|
||||
interface Props {
|
||||
behaviors?: CustomBehaviorOption[];
|
||||
data: CustomGraphData;
|
||||
selected?: string;
|
||||
height?: string;
|
||||
autoFit?: 'view' | 'center';
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const graphRef = shallowRef<Graph | null>(null);
|
||||
|
||||
// 监听容器尺寸变化,调整画布大小为图容器大小
|
||||
const onContainerResize = useDebounceFn(() => {
|
||||
if (graphRef.value) {
|
||||
graphRef.value.resize();
|
||||
}
|
||||
}, 5);
|
||||
|
||||
async function draw() {
|
||||
if (graphRef.value) {
|
||||
graphRef.value.destroy();
|
||||
}
|
||||
const { graph } = useAntFlow({
|
||||
container: 'antv-flow',
|
||||
data: props.data,
|
||||
behaviors: props.behaviors,
|
||||
autoFit: props.autoFit
|
||||
});
|
||||
graphRef.value = graph;
|
||||
await selectNode();
|
||||
}
|
||||
|
||||
async function selectNode() {
|
||||
if (props.selected && graphRef.value) {
|
||||
try {
|
||||
await graphRef.value.setElementState(props.selected, 'selected');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
graphRef.value?.zoomBy(0.9);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
graphRef.value?.zoomBy(1.1);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
graphRef.value?.zoomTo(1);
|
||||
graphRef.value?.fitCenter();
|
||||
}
|
||||
|
||||
function fitZoom() {
|
||||
graphRef.value?.fitView();
|
||||
graphRef.value?.fitCenter();
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.data, () => props.selected],
|
||||
() => {
|
||||
draw();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({ selectNode, graph: graphRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- canvas toolbar -->
|
||||
<div class="absolute left-0 right-0 z-1 flex items-center items-stretch justify-between">
|
||||
<ElButtonGroup size="small" class="bg-white!">
|
||||
<ElButton @click="zoomOut">
|
||||
<icon-mingcute:zoom-out-line />
|
||||
</ElButton>
|
||||
<ElButton @click="zoomIn">
|
||||
<icon-mingcute:zoom-in-line />
|
||||
</ElButton>
|
||||
<ElButton @click="resetZoom">
|
||||
<icon-icon-park-outline:equal-ratio />
|
||||
</ElButton>
|
||||
<ElButton @click="fitZoom">
|
||||
<icon-gg:ratio />
|
||||
</ElButton>
|
||||
</ElButtonGroup>
|
||||
<div class="flex-center gap-12px">
|
||||
<ElPopover placement="bottom-end" :width="200" :animated="false">
|
||||
<template #reference>
|
||||
<ElButton size="small" class="bg-white!">
|
||||
<icon-fe:question />
|
||||
</ElButton>
|
||||
</template>
|
||||
<div class="flex-col gap-8px">
|
||||
<div span="2" class="text-12px font-bold">节点图例</div>
|
||||
<ElRow>
|
||||
<ElCol v-for="(config, status) in nodeStatus" :key="status" :span="12" class="mb-8px flex-center">
|
||||
<ElTag size="small" round :bordered="false">
|
||||
<template #default>
|
||||
<icon-f7:flag-circle-fill v-if="status === 'MILESTONE'" :style="{ color: config.color }" />
|
||||
<icon-f7:circle-fill v-else :style="{ color: config.color }" />
|
||||
{{ config.type }}
|
||||
</template>
|
||||
</ElTag>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- canvas container -->
|
||||
<div
|
||||
id="antv-flow"
|
||||
ref="containerRef"
|
||||
v-resize-observer="onContainerResize"
|
||||
class="w-full"
|
||||
:style="{ height: props.height || '300px' }"
|
||||
@contextmenu="event => event.preventDefault()"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Graph } from '@antv/g6';
|
||||
import type { CustomBehaviorOption, IPointerEvent } from '@antv/g6';
|
||||
import type { Canvas } from '@antv/g6/lib/runtime/canvas';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getNodeIcon, nodeStatus } from './status';
|
||||
import type { CustomEdgeData, CustomGraphData, CustomNodeData } from './types';
|
||||
|
||||
interface AntFlowConfig {
|
||||
container: string | HTMLElement | Canvas;
|
||||
data: CustomGraphData;
|
||||
behaviors?: CustomBehaviorOption[];
|
||||
autoFit?: 'view' | 'center';
|
||||
}
|
||||
|
||||
export function useAntFlow(config: AntFlowConfig) {
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const baseColor = 'rgb(158 163 171)';
|
||||
|
||||
const { container, autoFit = 'center', data, behaviors = [] } = config;
|
||||
|
||||
const graph = new Graph({
|
||||
container,
|
||||
animation: false,
|
||||
padding: 16,
|
||||
theme: 'light',
|
||||
autoFit,
|
||||
data,
|
||||
node: {
|
||||
type: 'rect',
|
||||
|
||||
style: (node: CustomNodeData) => {
|
||||
const iconS = getNodeIcon(node);
|
||||
let labelFill = '#000000';
|
||||
if (node.taskState === 'NOT_STARTED') {
|
||||
labelFill = '#787878';
|
||||
}
|
||||
|
||||
return {
|
||||
labelText: node.name as string,
|
||||
size: [120, 26],
|
||||
radius: 99,
|
||||
fill: '#FFFFFF',
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : baseColor,
|
||||
lineDash: node.isDeleted ? 4 : 0,
|
||||
lineWidth: 1,
|
||||
labelFill,
|
||||
labelX: 2,
|
||||
labelY: 2,
|
||||
labelTextBaseline: 'middle',
|
||||
labelTextAlign: 'center',
|
||||
labelLineHeight: 13,
|
||||
labelWordWrap: true,
|
||||
labelMaxWidth: 72,
|
||||
iconSrc: iconS,
|
||||
iconWidth: 16,
|
||||
iconHeight: 16,
|
||||
iconX: -45,
|
||||
labelFontSize: 12,
|
||||
labelPlacement: 'center',
|
||||
badgeLineWidth: 6,
|
||||
badgeFontSize: 8,
|
||||
badges: [
|
||||
{ text: '延期', placement: 'top', offsetY: -11, visibility: node.isDelayed ? 'visible' : 'hidden' },
|
||||
{ text: '已删除', placement: 'bottom', offsetY: 11, visibility: node.isDeleted ? 'visible' : 'hidden' }
|
||||
],
|
||||
badgePalette: [themeStore.otherColor.error, themeStore.otherColor.error],
|
||||
ports: [{ placement: 'left' }, { placement: 'right' }]
|
||||
};
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
lineWidth: 2,
|
||||
stroke: themeStore.themeColor,
|
||||
labelFill: themeStore.themeColor,
|
||||
halo: true,
|
||||
haloStroke: themeStore.themeColor,
|
||||
haloLineWidth: 6
|
||||
},
|
||||
active: (node: CustomNodeData) => ({
|
||||
halo: true,
|
||||
haloStroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
haloLineWidth: 6,
|
||||
zIndex: 2
|
||||
})
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
type: 'cubic-horizontal',
|
||||
style: (node: CustomEdgeData) => ({
|
||||
curveOffset: 10,
|
||||
curvePosition: 0.5,
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : baseColor,
|
||||
lineDash: node.isDeleted ? 4 : 0
|
||||
}),
|
||||
state: {
|
||||
active: (node: CustomEdgeData) => ({
|
||||
lineWidth: 2,
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
halo: true,
|
||||
haloStroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
haloLineWidth: 6,
|
||||
zIndex: 2
|
||||
})
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
type: 'antv-dagre',
|
||||
rankdir: 'LR',
|
||||
ranksep: 20,
|
||||
nodesep: -20,
|
||||
controlPoints: true
|
||||
},
|
||||
behaviors: [
|
||||
{
|
||||
key: 'hover-activate',
|
||||
type: 'hover-activate',
|
||||
degree: 1,
|
||||
direction: 'both'
|
||||
},
|
||||
'drag-canvas',
|
||||
...behaviors
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
type: 'tooltip',
|
||||
enable: (event: IPointerEvent) => event.targetType === 'node',
|
||||
getContent: (_event: IPointerEvent, items?: CustomNodeData[]) => {
|
||||
let result = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
// 弹出提示可以自定义各种内容,但是这里很奇怪,有的class不跟随unocss的样式
|
||||
items?.forEach(item => {
|
||||
result += `
|
||||
<h3 style="display: flex; align-items: center; gap: 8px;">${item.name}</h3>
|
||||
<div style="display: flex;">
|
||||
<b>状态:</b>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<img src="${getNodeIcon(item)}" />
|
||||
<span style="font-weight: 400 !important;">${nodeStatus[item.status as keyof typeof nodeStatus].type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; row-gap: 4px;">
|
||||
<div style="display: flex; flex-direction: column;"><div style="color: rgb(156 163 175);">预计开始</div>
|
||||
<div style="font-weight: 700;">${item.startDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">预计结束</div>
|
||||
<div style="font-weight: 700;">${item.endDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">实际开始</div>
|
||||
<div style="font-weight: 700;">${item.actualStartDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">实际结束</div>
|
||||
<div style="font-weight: 700;">${item.actualEndDate || '-'}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
result += '</div>';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
graph.render();
|
||||
|
||||
return { graph };
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import type { TagProps } from 'element-plus';
|
||||
import type { CustomNodeData, NodeStatus } from './types';
|
||||
|
||||
interface NodeStatusConfig {
|
||||
type: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
base64: string;
|
||||
flag64: string;
|
||||
}
|
||||
|
||||
export const nodeStatus: Record<NodeStatus, NodeStatusConfig> = {
|
||||
MILESTONE: {
|
||||
type: '里程碑',
|
||||
color: '#5b5b5b',
|
||||
textColor: '',
|
||||
base64: '',
|
||||
flag64: ''
|
||||
},
|
||||
NOT_STARTED: {
|
||||
type: '未开始',
|
||||
color: '#CCCDD0',
|
||||
textColor: '#5b5b5b',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0NERDAiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0NERDAiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
DELAYED: {
|
||||
type: '已延期',
|
||||
color: '#B81111',
|
||||
textColor: '#dccbcb',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNCODExMTEiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNCODExMTEiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
PAUSED: {
|
||||
type: '已暂停',
|
||||
color: '#0E42D2',
|
||||
textColor: '#dae0f0',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMwRTQyRDIiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMwRTQyRDIiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
type: '进行中',
|
||||
color: '#E1BE0D',
|
||||
textColor: '#4f4304',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNFMUJFMEQiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNFMUJFMEQiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED: {
|
||||
type: '已完成',
|
||||
color: '#33C73D',
|
||||
textColor: '#084e0c',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMzM0M3M0QiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMzM0M3M0QiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED_EARLY: {
|
||||
type: '提前完成',
|
||||
color: '#CCFF99',
|
||||
textColor: '#42681d',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0ZGOTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0ZGOTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED_LATE: {
|
||||
type: '延期完成',
|
||||
color: '#CC6699',
|
||||
textColor: '#4b092a',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQzY2OTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQzY2OTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
}
|
||||
};
|
||||
|
||||
export function getNodeIcon(node: CustomNodeData) {
|
||||
if (!node.status) return '';
|
||||
|
||||
const type = node.milestone ? 'flag64' : 'base64';
|
||||
|
||||
return nodeStatus[node.status][type];
|
||||
}
|
||||
|
||||
export function getNodeStatusTag(state: NodeStatus, tagProperty?: TagProps) {
|
||||
const { color, type } = nodeStatus[state] || {};
|
||||
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
color,
|
||||
size: 'small',
|
||||
...tagProperty
|
||||
},
|
||||
{
|
||||
default: () => type
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { EdgeData, GraphData, NodeData } from '@antv/g6';
|
||||
|
||||
export type NodeStatus =
|
||||
| 'MILESTONE'
|
||||
| 'NOT_STARTED'
|
||||
| 'DELAYED'
|
||||
| 'PAUSED'
|
||||
| 'IN_PROGRESS'
|
||||
| 'COMPLETED'
|
||||
| 'COMPLETED_EARLY'
|
||||
| 'COMPLETED_LATE';
|
||||
|
||||
export interface CustomNodeData extends NodeData {
|
||||
isDelayed?: boolean;
|
||||
isDeleted?: boolean;
|
||||
milestone?: boolean;
|
||||
status?: NodeStatus;
|
||||
}
|
||||
|
||||
export interface CustomEdgeData extends EdgeData {
|
||||
isDelayed?: boolean;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomGraphData extends GraphData {
|
||||
nodes: CustomNodeData[];
|
||||
edges: CustomEdgeData[];
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
import { graphic } from 'echarts';
|
||||
import type { ScatterSeriesOption } from 'echarts/charts';
|
||||
import type { SingleAxisComponentOption, TitleComponentOption } from 'echarts/components';
|
||||
import type { ECOption } from '@/hooks/common/echarts';
|
||||
|
||||
export const pieOptions: ECOption = {
|
||||
legend: {},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
mark: { show: true },
|
||||
dataView: { show: true, readOnly: false },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Nightingale Chart',
|
||||
type: 'pie',
|
||||
radius: [50, 150],
|
||||
center: ['50%', '50%'],
|
||||
roseType: 'area',
|
||||
itemStyle: {
|
||||
borderRadius: 8
|
||||
},
|
||||
data: [
|
||||
{ value: 40, name: 'rose 1' },
|
||||
{ value: 38, name: 'rose 2' },
|
||||
{ value: 32, name: 'rose 3' },
|
||||
{ value: 30, name: 'rose 4' },
|
||||
{ value: 28, name: 'rose 5' },
|
||||
{ value: 26, name: 'rose 6' },
|
||||
{ value: 22, name: 'rose 7' },
|
||||
{ value: 18, name: 'rose 8' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const lineOptions: ECOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: 'Stacked Line'
|
||||
},
|
||||
legend: {
|
||||
data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color: '#37a2da',
|
||||
name: 'Email',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#37a2da'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [120, 132, 101, 134, 90, 230, 210]
|
||||
},
|
||||
{
|
||||
color: '#9fe6b8',
|
||||
name: 'Union Ads',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#9fe6b8'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [220, 182, 191, 234, 290, 330, 310]
|
||||
},
|
||||
{
|
||||
color: '#fedb5c',
|
||||
name: 'Video Ads',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#fedb5c'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [150, 232, 201, 154, 190, 330, 410]
|
||||
},
|
||||
{
|
||||
color: '#fb7293',
|
||||
name: 'Direct',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#fb7293'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [320, 332, 301, 334, 390, 330, 320]
|
||||
},
|
||||
{
|
||||
color: '#e7bcf3',
|
||||
name: 'Search Engine',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#e7bcf3'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [820, 932, 901, 934, 1290, 1330, 1320]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const barOptions: ECOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [120, 200, 150, 80, 70, 110, 130],
|
||||
type: 'bar',
|
||||
color: '#8378ea',
|
||||
showBackground: true,
|
||||
barGap: 100,
|
||||
itemStyle: {
|
||||
borderRadius: [40, 40, 0, 0]
|
||||
},
|
||||
backgroundStyle: {
|
||||
color: 'rgba(180, 180, 180, 0.2)'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function getPictorialBarOption(): ECOption {
|
||||
const category: string[] = [];
|
||||
let dottedBase = Number(new Date());
|
||||
const lineData: number[] = [];
|
||||
const barData: number[] = [];
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const date = new Date((dottedBase += 3600 * 24 * 1000));
|
||||
category.push([date.getFullYear(), date.getMonth() + 1, date.getDate()].join('-'));
|
||||
const b = Math.random() * 200;
|
||||
const d = Math.random() * 200;
|
||||
barData.push(b);
|
||||
lineData.push(d + b);
|
||||
}
|
||||
|
||||
const options: ECOption = {
|
||||
backgroundColor: '#0f375f',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['line', 'bar'],
|
||||
textStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
data: category,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
splitLine: { show: false },
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'line',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showAllSymbol: true,
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 15,
|
||||
data: lineData
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
type: 'bar',
|
||||
barWidth: 10,
|
||||
itemStyle: {
|
||||
borderRadius: 5,
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#14c8d4' },
|
||||
{ offset: 1, color: '#43eec6' }
|
||||
])
|
||||
},
|
||||
data: barData
|
||||
},
|
||||
{
|
||||
name: 'line',
|
||||
type: 'bar',
|
||||
barGap: '-100%',
|
||||
barWidth: 10,
|
||||
itemStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(20,200,212,0.5)' },
|
||||
{ offset: 0.2, color: 'rgba(20,200,212,0.2)' },
|
||||
{ offset: 1, color: 'rgba(20,200,212,0)' }
|
||||
])
|
||||
},
|
||||
z: -12,
|
||||
data: lineData
|
||||
},
|
||||
{
|
||||
name: 'dotted',
|
||||
type: 'pictorialBar',
|
||||
symbol: 'rect',
|
||||
itemStyle: {
|
||||
color: '#0f375f'
|
||||
},
|
||||
symbolRepeat: true,
|
||||
symbolSize: [12, 4],
|
||||
symbolMargin: 1,
|
||||
z: -10,
|
||||
data: lineData
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getScatterOption() {
|
||||
// prettier-ignore
|
||||
const hours = ['12a', '1a', '2a', '3a', '4a', '5a', '6a', '7a', '8a', '9a','10a','11a', '12p', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '10p', '11p'];
|
||||
|
||||
// prettier-ignore
|
||||
const days = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday'];
|
||||
|
||||
// prettier-ignore
|
||||
const data: [number, number, number][] = [[0,0,5],[0,1,1],[0,2,0],[0,3,0],[0,4,0],[0,5,0],[0,6,0],[0,7,0],[0,8,0],[0,9,0],[0,10,0],[0,11,2],[0,12,4],[0,13,1],[0,14,1],[0,15,3],[0,16,4],[0,17,6],[0,18,4],[0,19,4],[0,20,3],[0,21,3],[0,22,2],[0,23,5],[1,0,7],[1,1,0],[1,2,0],[1,3,0],[1,4,0],[1,5,0],[1,6,0],[1,7,0],[1,8,0],[1,9,0],[1,10,5],[1,11,2],[1,12,2],[1,13,6],[1,14,9],[1,15,11],[1,16,6],[1,17,7],[1,18,8],[1,19,12],[1,20,5],[1,21,5],[1,22,7],[1,23,2],[2,0,1],[2,1,1],[2,2,0],[2,3,0],[2,4,0],[2,5,0],[2,6,0],[2,7,0],[2,8,0],[2,9,0],[2,10,3],[2,11,2],[2,12,1],[2,13,9],[2,14,8],[2,15,10],[2,16,6],[2,17,5],[2,18,5],[2,19,5],[2,20,7],[2,21,4],[2,22,2],[2,23,4],[3,0,7],[3,1,3],[3,2,0],[3,3,0],[3,4,0],[3,5,0],[3,6,0],[3,7,0],[3,8,1],[3,9,0],[3,10,5],[3,11,4],[3,12,7],[3,13,14],[3,14,13],[3,15,12],[3,16,9],[3,17,5],[3,18,5],[3,19,10],[3,20,6],[3,21,4],[3,22,4],[3,23,1],[4,0,1],[4,1,3],[4,2,0],[4,3,0],[4,4,0],[4,5,1],[4,6,0],[4,7,0],[4,8,0],[4,9,2],[4,10,4],[4,11,4],[4,12,2],[4,13,4],[4,14,4],[4,15,14],[4,16,12],[4,17,1],[4,18,8],[4,19,5],[4,20,3],[4,21,7],[4,22,3],[4,23,0],[5,0,2],[5,1,1],[5,2,0],[5,3,3],[5,4,0],[5,5,0],[5,6,0],[5,7,0],[5,8,2],[5,9,0],[5,10,4],[5,11,1],[5,12,5],[5,13,10],[5,14,5],[5,15,7],[5,16,11],[5,17,6],[5,18,0],[5,19,5],[5,20,3],[5,21,4],[5,22,2],[5,23,0],[6,0,1],[6,1,0],[6,2,0],[6,3,0],[6,4,0],[6,5,0],[6,6,0],[6,7,0],[6,8,0],[6,9,0],[6,10,1],[6,11,0],[6,12,2],[6,13,1],[6,14,3],[6,15,4],[6,16,0],[6,17,0],[6,18,0],[6,19,0],[6,20,1],[6,21,2],[6,22,2],[6,23,6]];
|
||||
|
||||
const title: TitleComponentOption[] = [];
|
||||
const singleAxis: SingleAxisComponentOption[] = [];
|
||||
const series: ScatterSeriesOption[] = [];
|
||||
|
||||
days.forEach((day, idx) => {
|
||||
title.push({
|
||||
textBaseline: 'middle',
|
||||
top: `${((idx + 0.5) * 100) / 7}%`,
|
||||
text: day
|
||||
});
|
||||
singleAxis.push({
|
||||
left: 150,
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: hours,
|
||||
top: `${(idx * 100) / 7 + 5}%`,
|
||||
height: `${100 / 7 - 10}%`,
|
||||
axisLabel: {
|
||||
interval: 2
|
||||
}
|
||||
});
|
||||
series.push({
|
||||
singleAxisIndex: idx,
|
||||
coordinateSystem: 'singleAxis',
|
||||
type: 'scatter',
|
||||
data: [],
|
||||
symbolSize(dataItem) {
|
||||
return dataItem[1] * 4;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
data.forEach(dataItem => {
|
||||
(series as any)[dataItem[0]].data.push([dataItem[1], dataItem[2]]);
|
||||
});
|
||||
|
||||
const option: ECOption = {
|
||||
tooltip: {
|
||||
position: 'top'
|
||||
},
|
||||
title,
|
||||
singleAxis,
|
||||
series: series as any
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
export const radarOptions: ECOption = {
|
||||
title: {
|
||||
text: 'Multiple Radar'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
left: 'center',
|
||||
data: ['A Software', 'A Phone', 'Another Phone', 'Precipitation', 'Evaporation']
|
||||
},
|
||||
radar: [
|
||||
{
|
||||
indicator: [
|
||||
{ name: 'Brand', max: 100 },
|
||||
{ name: 'Content', max: 100 },
|
||||
{ name: 'Usability', max: 100 },
|
||||
{ name: 'Function', max: 100 }
|
||||
],
|
||||
center: ['25%', '40%'],
|
||||
radius: 80
|
||||
},
|
||||
{
|
||||
indicator: [
|
||||
{ name: 'Look', max: 100 },
|
||||
{ name: 'Photo', max: 100 },
|
||||
{ name: 'System', max: 100 },
|
||||
{ name: 'Performance', max: 100 },
|
||||
{ name: 'Screen', max: 100 }
|
||||
],
|
||||
radius: 80,
|
||||
center: ['50%', '60%']
|
||||
},
|
||||
{
|
||||
indicator: (() => {
|
||||
const res = [];
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
res.push({ name: `${i}月`, max: 100 });
|
||||
}
|
||||
return res;
|
||||
})(),
|
||||
center: ['75%', '40%'],
|
||||
radius: 80
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
value: [60, 73, 85, 40],
|
||||
name: 'A Software'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'radar',
|
||||
radarIndex: 1,
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
value: [85, 90, 90, 95, 95],
|
||||
name: 'A Phone'
|
||||
},
|
||||
{
|
||||
value: [95, 80, 95, 90, 93],
|
||||
name: 'Another Phone'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'radar',
|
||||
radarIndex: 2,
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
name: 'Precipitation',
|
||||
value: [2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 75.6, 82.2, 48.7, 18.8, 6.0, 2.3]
|
||||
},
|
||||
{
|
||||
name: 'Evaporation',
|
||||
value: [2.0, 4.9, 7.0, 23.2, 25.6, 76.7, 35.6, 62.2, 32.6, 20.0, 6.4, 3.3]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const gaugeOptions: ECOption = {
|
||||
series: [
|
||||
{
|
||||
name: 'hour',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 12,
|
||||
splitNumber: 12,
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 15,
|
||||
color: [[1, 'rgba(0,0,0,0.7)']],
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
shadowBlur: 15
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 3,
|
||||
shadowOffsetX: 1,
|
||||
shadowOffsetY: 2
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 50,
|
||||
distance: 25,
|
||||
formatter(value) {
|
||||
if (value === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
icon: 'path://M532.8,70.8C532.8,70.8,532.8,70.8,532.8,70.8L532.8,70.8C532.7,70.8,532.8,70.8,532.8,70.8z M456.1,49.6c-2.2-6.2-8.1-10.6-15-10.6h-37.5v10.6h37.5l0,0c2.9,0,5.3,2.4,5.3,5.3c0,2.9-2.4,5.3-5.3,5.3v0h-22.5c-1.5,0.1-3,0.4-4.3,0.9c-4.5,1.6-8.1,5.2-9.7,9.8c-0.6,1.7-0.9,3.4-0.9,5.3v16h10.6v-16l0,0l0,0c0-2.7,2.1-5,4.7-5.3h10.3l10.4,21.2h11.8l-10.4-21.2h0c6.9,0,12.8-4.4,15-10.6c0.6-1.7,0.9-3.5,0.9-5.3C457,53,456.7,51.2,456.1,49.6z M388.9,92.1h11.3L381,39h-3.6h-11.3L346.8,92v0h11.3l3.9-10.7h7.3h7.7l3.9-10.6h-7.7h-7.3l7.7-21.2v0L388.9,92.1z M301,38.9h-10.6v53.1H301V70.8h28.4l3.7-10.6H301V38.9zM333.2,38.9v10.6v10.7v31.9h10.6V38.9H333.2z M249.5,81.4L249.5,81.4L249.5,81.4c-2.9,0-5.3-2.4-5.3-5.3h0V54.9h0l0,0c0-2.9,2.4-5.3,5.3-5.3l0,0l0,0h33.6l3.9-10.6h-37.5c-1.9,0-3.6,0.3-5.3,0.9c-4.5,1.6-8.1,5.2-9.7,9.7c-0.6,1.7-0.9,3.5-0.9,5.3l0,0v21.3c0,1.9,0.3,3.6,0.9,5.3c1.6,4.5,5.2,8.1,9.7,9.7c1.7,0.6,3.5,0.9,5.3,0.9h33.6l3.9-10.6H249.5z M176.8,38.9v10.6h49.6l3.9-10.6H176.8z M192.7,81.4L192.7,81.4L192.7,81.4c-2.9,0-5.3-2.4-5.3-5.3l0,0v-5.3h38.9l3.9-10.6h-53.4v10.6v5.3l0,0c0,1.9,0.3,3.6,0.9,5.3c1.6,4.5,5.2,8.1,9.7,9.7c1.7,0.6,3.4,0.9,5.3,0.9h23.4h10.2l3.9-10.6l0,0H192.7z M460.1,38.9v10.6h21.4v42.5h10.6V49.6h17.5l3.8-10.6H460.1z M541.6,68.2c-0.2,0.1-0.4,0.3-0.7,0.4C541.1,68.4,541.4,68.3,541.6,68.2L541.6,68.2z M554.3,60.2h-21.6v0l0,0c-2.9,0-5.3-2.4-5.3-5.3c0-2.9,2.4-5.3,5.3-5.3l0,0l0,0h33.6l3.8-10.6h-37.5l0,0c-6.9,0-12.8,4.4-15,10.6c-0.6,1.7-0.9,3.5-0.9,5.3c0,1.9,0.3,3.7,0.9,5.3c2.2,6.2,8.1,10.6,15,10.6h21.6l0,0c2.9,0,5.3,2.4,5.3,5.3c0,2.9-2.4,5.3-5.3,5.3l0,0h-37.5v10.6h37.5c6.9,0,12.8-4.4,15-10.6c0.6-1.7,0.9-3.5,0.9-5.3c0-1.9-0.3-3.7-0.9-5.3C567.2,64.6,561.3,60.2,554.3,60.2z',
|
||||
showAbove: false,
|
||||
offsetCenter: [0, '-35%'],
|
||||
size: 120,
|
||||
keepAspect: true,
|
||||
itemStyle: {
|
||||
color: '#707177'
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 12,
|
||||
length: '55%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: [0, '30%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'minute',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 60,
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
show: false
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 8,
|
||||
length: '70%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
size: 20,
|
||||
showAbove: false,
|
||||
itemStyle: {
|
||||
borderWidth: 15,
|
||||
borderColor: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: ['0%', '-40%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'second',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 60,
|
||||
animationEasingUpdate: 'bounceOut',
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
show: false
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 4,
|
||||
length: '85%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
size: 15,
|
||||
showAbove: true,
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: ['0%', '-40%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
import {
|
||||
barOptions,
|
||||
gaugeOptions,
|
||||
getPictorialBarOption,
|
||||
getScatterOption,
|
||||
lineOptions,
|
||||
pieOptions,
|
||||
radarOptions
|
||||
} from './data';
|
||||
|
||||
defineOptions({ name: 'EchartsDemo' });
|
||||
|
||||
const { domRef: pieRef } = useEcharts(() => pieOptions, { onRender() {} });
|
||||
const { domRef: lineRef } = useEcharts(() => lineOptions, { onRender() {} });
|
||||
const { domRef: barRef } = useEcharts(() => barOptions, { onRender() {} });
|
||||
const { domRef: pictorialBarRef } = useEcharts(() => getPictorialBarOption(), { onRender() {} });
|
||||
const { domRef: radarRef } = useEcharts(() => radarOptions, { onRender() {} });
|
||||
const { domRef: scatterRef } = useEcharts(() => getScatterOption(), { onRender() {} });
|
||||
const { domRef: gaugeRef, setOptions: setGaugeOptions } = useEcharts(() => gaugeOptions, { onRender() {} });
|
||||
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
function initGaugeChart() {
|
||||
intervalId = setInterval(() => {
|
||||
const date = new Date();
|
||||
const second = date.getSeconds();
|
||||
const minute = date.getMinutes() + second / 60;
|
||||
const hour = (date.getHours() % 12) + minute / 60;
|
||||
|
||||
setGaugeOptions({
|
||||
animationDurationUpdate: 300,
|
||||
series: [
|
||||
{
|
||||
name: 'hour',
|
||||
animation: hour !== 0,
|
||||
data: [{ value: hour }]
|
||||
},
|
||||
{
|
||||
name: 'minute',
|
||||
animation: minute !== 0,
|
||||
data: [{ value: minute }]
|
||||
},
|
||||
{
|
||||
animation: second !== 0,
|
||||
name: 'second',
|
||||
data: [{ value: second }]
|
||||
}
|
||||
]
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearGaugeChart() {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
initGaugeChart();
|
||||
|
||||
onUnmounted(() => {
|
||||
clearGaugeChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace fill :size="16">
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="pieRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="lineRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="barRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="radarRef" class="h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="scatterRef" class="h-600px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="pictorialBarRef" class="h-600px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="gaugeRef" class="h-640px" />
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,872 +0,0 @@
|
||||
import type {
|
||||
IAnimationConfig,
|
||||
IAreaChartSpec,
|
||||
IBarChartSpec,
|
||||
ICircularProgressChartSpec,
|
||||
IHistogramChartSpec,
|
||||
IIndicatorSpec,
|
||||
ILiquidChartSpec,
|
||||
IWordCloudChartSpec
|
||||
} from '@visactor/vchart';
|
||||
|
||||
export const shapeWordCloudSpec: IWordCloudChartSpec = {
|
||||
type: 'wordCloud',
|
||||
maskShape: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/log.jpeg',
|
||||
nameField: 'challenge_name',
|
||||
valueField: 'sum_count',
|
||||
seriesField: 'challenge_name',
|
||||
data: [
|
||||
{
|
||||
name: 'data',
|
||||
values: [
|
||||
{
|
||||
challenge_name: '刘浩存',
|
||||
sum_count: 957
|
||||
},
|
||||
{
|
||||
challenge_name: '刘昊然',
|
||||
sum_count: 942
|
||||
},
|
||||
{
|
||||
challenge_name: '喜欢',
|
||||
sum_count: 842
|
||||
},
|
||||
{
|
||||
challenge_name: '真的',
|
||||
sum_count: 828
|
||||
},
|
||||
{
|
||||
challenge_name: '四海',
|
||||
sum_count: 665
|
||||
},
|
||||
{
|
||||
challenge_name: '好看',
|
||||
sum_count: 627
|
||||
},
|
||||
{
|
||||
challenge_name: '评论',
|
||||
sum_count: 574
|
||||
},
|
||||
{
|
||||
challenge_name: '好像',
|
||||
sum_count: 564
|
||||
},
|
||||
{
|
||||
challenge_name: '沈腾',
|
||||
sum_count: 554
|
||||
},
|
||||
{
|
||||
challenge_name: '不像',
|
||||
sum_count: 540
|
||||
},
|
||||
{
|
||||
challenge_name: '多少钱',
|
||||
sum_count: 513
|
||||
},
|
||||
{
|
||||
challenge_name: '韩寒',
|
||||
sum_count: 513
|
||||
},
|
||||
{
|
||||
challenge_name: '不知道',
|
||||
sum_count: 499
|
||||
},
|
||||
{
|
||||
challenge_name: '感觉',
|
||||
sum_count: 499
|
||||
},
|
||||
{
|
||||
challenge_name: '尹正',
|
||||
sum_count: 495
|
||||
},
|
||||
{
|
||||
challenge_name: '不看',
|
||||
sum_count: 487
|
||||
},
|
||||
{
|
||||
challenge_name: '奥特之父',
|
||||
sum_count: 484
|
||||
},
|
||||
{
|
||||
challenge_name: '阿姨',
|
||||
sum_count: 482
|
||||
},
|
||||
{
|
||||
challenge_name: '支持',
|
||||
sum_count: 482
|
||||
},
|
||||
{
|
||||
challenge_name: '父母',
|
||||
sum_count: 479
|
||||
},
|
||||
{
|
||||
challenge_name: '一条',
|
||||
sum_count: 462
|
||||
},
|
||||
{
|
||||
challenge_name: '女主',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '确实',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '票房',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '无语',
|
||||
sum_count: 443
|
||||
},
|
||||
{
|
||||
challenge_name: '干干净净',
|
||||
sum_count: 443
|
||||
},
|
||||
{
|
||||
challenge_name: '为啥',
|
||||
sum_count: 426
|
||||
},
|
||||
{
|
||||
challenge_name: '爱情',
|
||||
sum_count: 425
|
||||
},
|
||||
{
|
||||
challenge_name: '喜剧',
|
||||
sum_count: 422
|
||||
},
|
||||
{
|
||||
challenge_name: '春节',
|
||||
sum_count: 414
|
||||
},
|
||||
{
|
||||
challenge_name: '剧情',
|
||||
sum_count: 414
|
||||
},
|
||||
{
|
||||
challenge_name: '人生',
|
||||
sum_count: 409
|
||||
},
|
||||
{
|
||||
challenge_name: '风格',
|
||||
sum_count: 408
|
||||
},
|
||||
{
|
||||
challenge_name: '演员',
|
||||
sum_count: 403
|
||||
},
|
||||
{
|
||||
challenge_name: '成长',
|
||||
sum_count: 403
|
||||
},
|
||||
{
|
||||
challenge_name: '玩意',
|
||||
sum_count: 402
|
||||
},
|
||||
{
|
||||
challenge_name: '文学',
|
||||
sum_count: 397
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const circularProgressTickSpec: ICircularProgressChartSpec & { indicator: IIndicatorSpec } = {
|
||||
type: 'circularProgress',
|
||||
data: [
|
||||
{
|
||||
id: 'id0',
|
||||
values: [
|
||||
{
|
||||
type: 'Tradition Industries',
|
||||
value: 0.795,
|
||||
text: '79.5%'
|
||||
},
|
||||
{
|
||||
type: 'Business Companies',
|
||||
value: 0.5,
|
||||
text: '50%'
|
||||
},
|
||||
{
|
||||
type: 'Customer-facing Companies',
|
||||
value: 0.25,
|
||||
text: '25%'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
color: ['rgb(255, 222, 0)', 'rgb(171, 205, 5)', 'rgb(0, 154, 68)'],
|
||||
valueField: 'value',
|
||||
categoryField: 'type',
|
||||
seriesField: 'type',
|
||||
radius: 0.8,
|
||||
innerRadius: 0.4,
|
||||
tickMask: {
|
||||
visible: true,
|
||||
angle: 10,
|
||||
offsetAngle: 0,
|
||||
forceAlign: true,
|
||||
style: {
|
||||
cornerRadius: 15
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
visible: false,
|
||||
type: 'linear',
|
||||
orient: 'angle'
|
||||
},
|
||||
{
|
||||
visible: false,
|
||||
type: 'band',
|
||||
orient: 'radius'
|
||||
}
|
||||
],
|
||||
indicator: {
|
||||
visible: true,
|
||||
trigger: 'hover',
|
||||
title: {
|
||||
visible: true,
|
||||
field: 'type',
|
||||
autoLimit: true,
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fill: 'black'
|
||||
}
|
||||
},
|
||||
content: [
|
||||
{
|
||||
visible: true,
|
||||
field: 'text',
|
||||
style: {
|
||||
fontSize: 16,
|
||||
fill: 'gray'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
legends: {
|
||||
visible: true,
|
||||
orient: 'bottom',
|
||||
title: {
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const liquidChartSmartInvertSpec: ILiquidChartSpec & { indicator: IIndicatorSpec } = {
|
||||
type: 'liquid',
|
||||
valueField: 'value',
|
||||
data: {
|
||||
id: 'data',
|
||||
values: [
|
||||
{
|
||||
value: 0.8
|
||||
}
|
||||
]
|
||||
},
|
||||
maskShape: 'drop', // 水滴
|
||||
// maskShape: 'circle',
|
||||
// maskShape: 'star',
|
||||
indicatorSmartInvert: true,
|
||||
indicator: {
|
||||
visible: true,
|
||||
title: {
|
||||
visible: true,
|
||||
style: {
|
||||
text: '进度'
|
||||
}
|
||||
},
|
||||
content: [
|
||||
{
|
||||
visible: true,
|
||||
style: {
|
||||
fill: 'black',
|
||||
text: '80%'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
liquidBackground: {
|
||||
style: {
|
||||
fill: 'blue'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goldenMedals: Record<number, any[]> = {
|
||||
2000: [
|
||||
{ country: 'USA', value: 37 },
|
||||
{ country: 'Russia', value: 32 },
|
||||
{ country: 'China', value: 28 },
|
||||
{ country: 'Australia', value: 16 },
|
||||
{ country: 'Germany', value: 13 },
|
||||
{ country: 'France', value: 13 },
|
||||
{ country: 'Italy', value: 13 },
|
||||
{ country: 'Netherlands', value: 12 },
|
||||
{ country: 'Cuba', value: 11 },
|
||||
{ country: 'U.K.', value: 11 }
|
||||
],
|
||||
2004: [
|
||||
{ country: 'USA', value: 36 },
|
||||
{ country: 'China', value: 32 },
|
||||
{ country: 'Russia', value: 28 },
|
||||
{ country: 'Australia', value: 17 },
|
||||
{ country: 'Japan', value: 16 },
|
||||
{ country: 'Germany', value: 13 },
|
||||
{ country: 'France', value: 11 },
|
||||
{ country: 'Italy', value: 10 },
|
||||
{ country: 'South Korea', value: 9 },
|
||||
{ country: 'U.K.', value: 9 }
|
||||
],
|
||||
2008: [
|
||||
{ country: 'China', value: 48 },
|
||||
{ country: 'USA', value: 36 },
|
||||
{ country: 'Russia', value: 24 },
|
||||
{ country: 'U.K.', value: 19 },
|
||||
{ country: 'Germany', value: 16 },
|
||||
{ country: 'Australia', value: 14 },
|
||||
{ country: 'South Korea', value: 13 },
|
||||
{ country: 'Japan', value: 9 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'France', value: 7 }
|
||||
],
|
||||
2012: [
|
||||
{ country: 'USA', value: 46 },
|
||||
{ country: 'China', value: 39 },
|
||||
{ country: 'U.K.', value: 29 },
|
||||
{ country: 'Russia', value: 19 },
|
||||
{ country: 'South Korea', value: 13 },
|
||||
{ country: 'Germany', value: 11 },
|
||||
{ country: 'France', value: 11 },
|
||||
{ country: 'Australia', value: 8 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'Hungary', value: 8 }
|
||||
],
|
||||
2016: [
|
||||
{ country: 'USA', value: 46 },
|
||||
{ country: 'U.K.', value: 27 },
|
||||
{ country: 'China', value: 26 },
|
||||
{ country: 'Russia', value: 19 },
|
||||
{ country: 'Germany', value: 17 },
|
||||
{ country: 'Japan', value: 12 },
|
||||
{ country: 'France', value: 10 },
|
||||
{ country: 'South Korea', value: 9 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'Australia', value: 8 }
|
||||
],
|
||||
2020: [
|
||||
{ country: 'USA', value: 39 },
|
||||
{ country: 'China', value: 38 },
|
||||
{ country: 'Japan', value: 27 },
|
||||
{ country: 'U.K.', value: 22 },
|
||||
{ country: 'Russian Olympic Committee', value: 20 },
|
||||
{ country: 'Australia', value: 17 },
|
||||
{ country: 'Netherlands', value: 10 },
|
||||
{ country: 'France', value: 10 },
|
||||
{ country: 'Germany', value: 10 },
|
||||
{ country: 'Italy', value: 10 }
|
||||
]
|
||||
};
|
||||
|
||||
const colors = {
|
||||
China: '#d62728',
|
||||
USA: '#1664FF',
|
||||
Russia: '#B2CFFF',
|
||||
'U.K.': '#1AC6FF',
|
||||
Australia: '#94EFFF',
|
||||
Japan: '#FF8A00',
|
||||
Cuba: '#FFCE7A',
|
||||
Germany: '#3CC780',
|
||||
France: '#B9EDCD',
|
||||
Italy: '#7442D4',
|
||||
'South Korea': '#DDC5FA',
|
||||
'Russian Olympic Committee': '#B2CFFF',
|
||||
Netherlands: '#FFC400',
|
||||
Hungary: '#FAE878'
|
||||
};
|
||||
|
||||
const dataSpecs = Object.keys(goldenMedals).map(year => {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 'id',
|
||||
values: (goldenMedals[year as unknown as number] as any)
|
||||
.sort((a: any, b: any) => b.value - a.value)
|
||||
.map((v: any) => {
|
||||
return { ...v, fill: (colors as any)[v.country] };
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'year',
|
||||
values: [{ year }]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
const duration = 1000;
|
||||
const exchangeDuration = 600;
|
||||
|
||||
export const rankingBarSpec: IBarChartSpec = {
|
||||
type: 'bar',
|
||||
padding: {
|
||||
top: 12,
|
||||
right: 100,
|
||||
bottom: 12
|
||||
},
|
||||
data: dataSpecs[0].data,
|
||||
direction: 'horizontal',
|
||||
yField: 'country',
|
||||
xField: 'value',
|
||||
seriesField: 'country',
|
||||
bar: {
|
||||
style: {
|
||||
fill: (datum: any) => datum.fill
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
animation: true,
|
||||
orient: 'bottom',
|
||||
type: 'linear',
|
||||
visible: true,
|
||||
max: 50,
|
||||
grid: {
|
||||
visible: true
|
||||
}
|
||||
},
|
||||
{
|
||||
animation: true,
|
||||
id: 'axis-left',
|
||||
orient: 'left',
|
||||
width: 130,
|
||||
tick: { visible: false },
|
||||
label: { visible: true },
|
||||
type: 'band'
|
||||
}
|
||||
],
|
||||
title: {
|
||||
visible: true,
|
||||
text: 'Top 10 Olympic Gold Medals by Country Since 2000'
|
||||
},
|
||||
animationUpdate: {
|
||||
bar: [
|
||||
{
|
||||
type: 'update',
|
||||
options: { excludeChannels: ['y'] },
|
||||
easing: 'linear',
|
||||
duration
|
||||
},
|
||||
{
|
||||
channel: ['y'],
|
||||
easing: 'circInOut',
|
||||
duration: exchangeDuration
|
||||
}
|
||||
],
|
||||
axis: {
|
||||
duration: exchangeDuration,
|
||||
easing: 'circInOut'
|
||||
}
|
||||
} as Record<string, IAnimationConfig>,
|
||||
animationEnter: {
|
||||
bar: [
|
||||
{
|
||||
type: 'moveIn',
|
||||
duration: exchangeDuration,
|
||||
easing: 'circInOut',
|
||||
options: {
|
||||
direction: 'y',
|
||||
orient: 'negative'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
animationExit: {
|
||||
bar: [
|
||||
{
|
||||
type: 'fadeOut',
|
||||
duration: exchangeDuration
|
||||
}
|
||||
]
|
||||
},
|
||||
customMark: [
|
||||
{
|
||||
type: 'text',
|
||||
dataId: 'year',
|
||||
style: {
|
||||
textBaseline: 'bottom',
|
||||
fontSize: 200,
|
||||
textAlign: 'right',
|
||||
fontFamily: 'PingFang SC',
|
||||
fontWeight: 600,
|
||||
text: (datum: any) => datum.year,
|
||||
x: (_datum: any, ctx: any) => {
|
||||
return ctx.vchart.getChart().getCanvasRect()?.width - 50;
|
||||
},
|
||||
y: (_datum: any, ctx: any) => {
|
||||
return ctx.vchart.getChart().getCanvasRect()?.height - 50;
|
||||
},
|
||||
fill: 'grey',
|
||||
fillOpacity: 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
player: {
|
||||
type: 'continuous',
|
||||
orient: 'bottom',
|
||||
auto: true,
|
||||
loop: true,
|
||||
dx: 80,
|
||||
position: 'middle',
|
||||
interval: duration,
|
||||
specs: dataSpecs,
|
||||
slider: {
|
||||
railStyle: {
|
||||
height: 6
|
||||
}
|
||||
},
|
||||
controller: {
|
||||
backward: {
|
||||
style: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
forward: {
|
||||
style: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
start: {
|
||||
order: 1,
|
||||
position: 'end'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stackedDashAreaSpec: IAreaChartSpec = {
|
||||
type: 'area',
|
||||
data: {
|
||||
values: [
|
||||
{ month: 'Jan', country: 'Africa', value: 4229 },
|
||||
{ month: 'Jan', country: 'EU', value: 4376 },
|
||||
{ month: 'Jan', country: 'China', value: 3054 },
|
||||
{ month: 'Jan', country: 'USA', value: 12814 },
|
||||
{ month: 'Feb', country: 'Africa', value: 3932 },
|
||||
{ month: 'Feb', country: 'EU', value: 3987 },
|
||||
{ month: 'Feb', country: 'China', value: 5067 },
|
||||
{ month: 'Feb', country: 'USA', value: 13012 },
|
||||
{ month: 'Mar', country: 'Africa', value: 5221 },
|
||||
{ month: 'Mar', country: 'EU', value: 3574 },
|
||||
{ month: 'Mar', country: 'China', value: 7004 },
|
||||
{ month: 'Mar', country: 'USA', value: 11624 },
|
||||
{ month: 'Apr', country: 'Africa', value: 9256 },
|
||||
{ month: 'Apr', country: 'EU', value: 4376 },
|
||||
{ month: 'Apr', country: 'China', value: 9054 },
|
||||
{ month: 'Apr', country: 'USA', value: 8814 },
|
||||
{ month: 'May', country: 'Africa', value: 3308 },
|
||||
{ month: 'May', country: 'EU', value: 4572 },
|
||||
{ month: 'May', country: 'China', value: 12043 },
|
||||
{ month: 'May', country: 'USA', value: 12998 },
|
||||
{ month: 'Jun', country: 'Africa', value: 5432 },
|
||||
{ month: 'Jun', country: 'EU', value: 3417 },
|
||||
{ month: 'Jun', country: 'China', value: 15067 },
|
||||
{ month: 'Jun', country: 'USA', value: 12321 },
|
||||
{ month: 'Jul', country: 'Africa', value: 13701 },
|
||||
{ month: 'Jul', country: 'EU', value: 5231 },
|
||||
{ month: 'Jul', country: 'China', value: 10119 },
|
||||
{ month: 'Jul', country: 'USA', value: 10342 },
|
||||
{ month: 'Aug', country: 'Africa', value: 4008, forecast: true },
|
||||
{ month: 'Aug', country: 'EU', value: 4572, forecast: true },
|
||||
{ month: 'Aug', country: 'China', value: 12043, forecast: true },
|
||||
{ month: 'Aug', country: 'USA', value: 22998, forecast: true },
|
||||
{ month: 'Sept', country: 'Africa', value: 18712, forecast: true },
|
||||
{ month: 'Sept', country: 'EU', value: 6134, forecast: true },
|
||||
{ month: 'Sept', country: 'China', value: 10419, forecast: true },
|
||||
{ month: 'Sept', country: 'USA', value: 11261, forecast: true }
|
||||
]
|
||||
},
|
||||
stack: true,
|
||||
xField: 'month',
|
||||
yField: 'value',
|
||||
seriesField: 'country',
|
||||
point: {
|
||||
style: {
|
||||
size: 0
|
||||
},
|
||||
state: {
|
||||
dimension_hover: {
|
||||
size: 10,
|
||||
outerBorder: {
|
||||
distance: 0,
|
||||
lineWidth: 6,
|
||||
strokeOpacity: 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
line: {
|
||||
style: {
|
||||
// Configure the lineDash attribute based on the forecast field value of the data
|
||||
lineDash: (data: any) => {
|
||||
if (data.forecast) {
|
||||
return [5, 5];
|
||||
}
|
||||
return [0];
|
||||
}
|
||||
}
|
||||
},
|
||||
area: {
|
||||
style: {
|
||||
fillOpacity: 0.5,
|
||||
textureColor: '#fff',
|
||||
textureSize: 14,
|
||||
// Configure the texture attribute based on the forecast field value of the data
|
||||
texture: (data: any) => {
|
||||
if (data.forecast) {
|
||||
return 'bias-rl';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
legends: [{ visible: true, position: 'middle', orient: 'bottom' }],
|
||||
crosshair: {
|
||||
xField: {
|
||||
visible: true,
|
||||
line: {
|
||||
type: 'line'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const barMarkPointSpec: IBarChartSpec = {
|
||||
type: 'bar',
|
||||
height: 300,
|
||||
data: [
|
||||
{
|
||||
id: 'barData',
|
||||
values: [
|
||||
{ time: '10:20', cost: 2 },
|
||||
{ time: '10:30', cost: 1 },
|
||||
{ time: '10:40', cost: 1 },
|
||||
{ time: '10:50', cost: 2 },
|
||||
{ time: '11:00', cost: 2 },
|
||||
{ time: '11:10', cost: 2 },
|
||||
{ time: '11:20', cost: 1 },
|
||||
{ time: '11:30', cost: 1 },
|
||||
{ time: '11:40', cost: 2 },
|
||||
{ time: '11:50', cost: 1 }
|
||||
]
|
||||
}
|
||||
],
|
||||
xField: 'time',
|
||||
yField: 'cost',
|
||||
crosshair: {
|
||||
xField: {
|
||||
visible: true,
|
||||
line: {
|
||||
type: 'rect',
|
||||
style: {
|
||||
fill: 'rgb(85,208,93)',
|
||||
fillOpacity: 0.1
|
||||
}
|
||||
},
|
||||
bindingAxesIndex: [1],
|
||||
defaultSelect: {
|
||||
axisIndex: 1,
|
||||
datum: '10:20'
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
animation: false,
|
||||
formatMethod: (datum: any) => `${datum}分钟`,
|
||||
style: {
|
||||
fill: 'rgb(155,155,155)'
|
||||
}
|
||||
},
|
||||
bar: {
|
||||
style: {
|
||||
fill: 'rgb(85,208,93)',
|
||||
cornerRadius: [4, 4, 0, 0],
|
||||
width: 30
|
||||
}
|
||||
},
|
||||
markPoint: [
|
||||
{
|
||||
coordinate: {
|
||||
time: '10:20',
|
||||
cost: 2
|
||||
},
|
||||
itemContent: {
|
||||
type: 'text',
|
||||
// autoRotate: false,
|
||||
offsetY: -10,
|
||||
text: {
|
||||
dy: 14,
|
||||
text: '2分钟',
|
||||
style: {
|
||||
fill: 'white',
|
||||
fontSize: 14
|
||||
},
|
||||
labelBackground: {
|
||||
padding: [5, 10, 5, 10],
|
||||
style: {
|
||||
fill: '#000',
|
||||
cornerRadius: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemLine: {
|
||||
endSymbol: {
|
||||
visible: true,
|
||||
style: {
|
||||
angle: Math.PI,
|
||||
scaleY: 0.4,
|
||||
fill: '#000',
|
||||
dy: 4,
|
||||
stroke: '#000'
|
||||
}
|
||||
},
|
||||
startSymbol: { visible: false },
|
||||
line: {
|
||||
style: {
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
animationUpdate: false,
|
||||
axes: [
|
||||
{
|
||||
orient: 'left',
|
||||
max: 10,
|
||||
label: { visible: false },
|
||||
grid: {
|
||||
style: { lineDash: [4, 4] }
|
||||
}
|
||||
},
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
formatMethod: (datum: any) => {
|
||||
return datum === '10:20' ? '当前' : datum;
|
||||
},
|
||||
style: (datum: any) => {
|
||||
return {
|
||||
fontSize: datum === '10:20' ? 14 : 12,
|
||||
fill: datum === '10:20' ? 'black' : 'grey'
|
||||
};
|
||||
}
|
||||
},
|
||||
paddingOuter: 0.5,
|
||||
paddingInner: 0,
|
||||
grid: {
|
||||
visible: true,
|
||||
alignWithLabel: false,
|
||||
style: { lineDash: [4, 4] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const histogramDifferentBinSpec: IHistogramChartSpec = {
|
||||
type: 'histogram',
|
||||
xField: 'from',
|
||||
x2Field: 'to',
|
||||
yField: 'profit',
|
||||
seriesField: 'type',
|
||||
bar: {
|
||||
style: {
|
||||
stroke: 'white',
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: 'Profit',
|
||||
textStyle: {
|
||||
align: 'center',
|
||||
height: 50,
|
||||
lineWidth: 3,
|
||||
fill: '#333',
|
||||
fontSize: 25,
|
||||
fontFamily: 'Times New Roman'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
visible: true,
|
||||
mark: {
|
||||
title: {
|
||||
key: 'title',
|
||||
value: 'profit'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum?: Record<string, any>) => `${datum?.from}~${datum?.to}`,
|
||||
value: (datum?: Record<string, any>) => datum?.profit
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
nice: false
|
||||
}
|
||||
],
|
||||
data: [
|
||||
{
|
||||
name: 'data1',
|
||||
values: [
|
||||
{
|
||||
from: 0,
|
||||
to: 10,
|
||||
profit: 2,
|
||||
type: 'A'
|
||||
},
|
||||
{
|
||||
from: 10,
|
||||
to: 16,
|
||||
profit: 3,
|
||||
type: 'B'
|
||||
},
|
||||
{
|
||||
from: 16,
|
||||
to: 18,
|
||||
profit: 15,
|
||||
type: 'C'
|
||||
},
|
||||
{
|
||||
from: 18,
|
||||
to: 26,
|
||||
profit: 12,
|
||||
type: 'D'
|
||||
},
|
||||
{
|
||||
from: 26,
|
||||
to: 32,
|
||||
profit: 22,
|
||||
type: 'E'
|
||||
},
|
||||
{
|
||||
from: 32,
|
||||
to: 56,
|
||||
profit: 7,
|
||||
type: 'F'
|
||||
},
|
||||
{
|
||||
from: 56,
|
||||
to: 62,
|
||||
profit: 17,
|
||||
type: 'G'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useVChart } from '@/hooks/common/vchart';
|
||||
import {
|
||||
barMarkPointSpec,
|
||||
circularProgressTickSpec,
|
||||
histogramDifferentBinSpec,
|
||||
liquidChartSmartInvertSpec,
|
||||
rankingBarSpec,
|
||||
shapeWordCloudSpec,
|
||||
stackedDashAreaSpec
|
||||
} from './data';
|
||||
|
||||
const { domRef: stackedDashAreaRef } = useVChart(() => stackedDashAreaSpec);
|
||||
const { domRef: barMarkPointRef } = useVChart(() => barMarkPointSpec);
|
||||
const { domRef: histogramDifferentBinRef } = useVChart(() => histogramDifferentBinSpec);
|
||||
const { domRef: rankingBarRef } = useVChart(() => rankingBarSpec);
|
||||
const { domRef: shapeWordCloudRef } = useVChart(() => shapeWordCloudSpec);
|
||||
const { domRef: circularProgressTickRef } = useVChart(() => circularProgressTickSpec);
|
||||
const { domRef: liquidChartSmartInvertRef } = useVChart(() => liquidChartSmartInvertSpec);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard header="VChart" class="h-full card-wrapper">
|
||||
<WebSiteLink label="More Demos: " link="https://www.visactor.com/vchart/example" />
|
||||
</ElCard>
|
||||
<ElCard header="Stacked Dash Area Chart" class="h-full card-wrapper">
|
||||
<div ref="stackedDashAreaRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Bar Mark Point Chart" class="h-full card-wrapper">
|
||||
<div ref="barMarkPointRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Histogram Different Bin Chart" class="h-full card-wrapper">
|
||||
<div ref="histogramDifferentBinRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Ranking Bar Chart" class="h-full card-wrapper">
|
||||
<div ref="rankingBarRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Circular Progress Tick Chart" class="h-full card-wrapper">
|
||||
<div ref="circularProgressTickRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Liquid Chart Smart Invert Chart" class="h-full card-wrapper">
|
||||
<div ref="liquidChartSmartInvertRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Shape Word Cloud Chart" class="h-full card-wrapper">
|
||||
<div ref="shapeWordCloudRef" class="h-400px" />
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'CopyPage' });
|
||||
|
||||
const { copy, isSupported } = useClipboard();
|
||||
|
||||
const source = ref('');
|
||||
|
||||
async function handleCopy() {
|
||||
if (!isSupported) {
|
||||
window.$message?.error('您的浏览器不支持Clipboard API');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!source.value) {
|
||||
window.$message?.error('请输入要复制的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
await copy(source.value);
|
||||
window.$message?.success(`复制成功:${source.value}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="文本复制" class="h-full card-wrapper">
|
||||
<ElInput v-model="source" placeholder="请输入要复制的内容吧">
|
||||
<template #append>
|
||||
<ElButton type="primary" @click="handleCopy">复制</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { reactive } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import { commonStatusRecord, userGenderRecord } from '@/constants/business';
|
||||
import { fetchGetUserList } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ExcelPage' });
|
||||
|
||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
status: undefined,
|
||||
username: undefined,
|
||||
mobile: undefined,
|
||||
deptId: undefined,
|
||||
roleId: undefined
|
||||
});
|
||||
|
||||
const { columns, data, loading } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
api: () => fetchGetUserList(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: searchParams.pageNo ?? 1,
|
||||
pageSize: searchParams.pageSize ?? 10,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage;
|
||||
searchParams.pageSize = params.pageSize;
|
||||
},
|
||||
columns: () => [
|
||||
{ type: 'selection', width: 48 },
|
||||
{ type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 100 },
|
||||
{
|
||||
prop: 'sex',
|
||||
label: $t('page.system.user.userGender'),
|
||||
width: 100,
|
||||
formatter: row => {
|
||||
const tagMap: Record<Api.SystemManage.UserGender, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'danger'
|
||||
};
|
||||
const value = row.sex ?? 0;
|
||||
|
||||
const label = $t(userGenderRecord[value]);
|
||||
|
||||
return <ElTag type={tagMap[value]}>{label}</ElTag>;
|
||||
}
|
||||
},
|
||||
{ prop: 'nickname', label: $t('page.system.user.nickName'), minWidth: 100 },
|
||||
{ prop: 'mobile', label: $t('page.system.user.userPhone'), width: 120 },
|
||||
{ prop: 'email', label: $t('page.system.user.userEmail'), minWidth: 200 },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.user.userStatus'),
|
||||
width: 100,
|
||||
formatter: row => {
|
||||
const tagMap: Record<Api.SystemManage.CommonStatus, UI.ThemeColor> = {
|
||||
0: 'success',
|
||||
1: 'warning'
|
||||
};
|
||||
|
||||
const label = $t(commonStatusRecord[row.status]);
|
||||
|
||||
return <ElTag type={tagMap[row.status]}>{label}</ElTag>;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function exportExcel() {
|
||||
const exportColumns = columns.value.slice(2);
|
||||
|
||||
const excelList = data.value.map(item => exportColumns.map(col => getTableValue(col, item)));
|
||||
|
||||
const titleList = exportColumns.map(col => (isTableColumnHasTitle(col) && col.label) || undefined);
|
||||
|
||||
excelList.unshift(titleList);
|
||||
|
||||
const workBook = utils.book_new();
|
||||
|
||||
const workSheet = utils.aoa_to_sheet(excelList);
|
||||
|
||||
workSheet['!cols'] = exportColumns.map(item => ({
|
||||
width: Math.round(Number(item.width) / 10 || 20)
|
||||
}));
|
||||
|
||||
utils.book_append_sheet(workBook, workSheet, '用户列表');
|
||||
|
||||
writeFile(workBook, '用户数据.xlsx');
|
||||
}
|
||||
|
||||
function getTableValue(col: UI.TableColumn<Api.SystemManage.User>, item: Api.SystemManage.User) {
|
||||
if (!isTableColumnHasKey(col)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { prop } = col;
|
||||
|
||||
if (prop === 'operate' || prop === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (prop === 'status') {
|
||||
return $t(commonStatusRecord[item.status]);
|
||||
}
|
||||
|
||||
if (prop === 'sex') {
|
||||
return $t(userGenderRecord[item.sex ?? 0]);
|
||||
}
|
||||
|
||||
if (prop in item) {
|
||||
return item[prop as keyof Api.SystemManage.User];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function isTableColumnHasKey<T>(column: UI.TableColumn<T>): boolean {
|
||||
return Boolean((column as UI.TableColumnWithKey<T>).prop);
|
||||
}
|
||||
|
||||
function isTableColumnHasTitle<T>(column: UI.TableColumn<T>): boolean {
|
||||
return Boolean((column as UI.TableColumnWithKey<T>).label);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Excel导出</p>
|
||||
<ElButton plain type="primary" @click="exportExcel">
|
||||
<template #icon>
|
||||
<icon-file-icons:microsoft-excel class="text-icon" />
|
||||
</template>
|
||||
导出excel
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="h-[calc(100%-50px)]">
|
||||
<ElTable v-loading="loading" height="100%" border class="sm:h-full" :data="data" row-key="id">
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { Task } from 'dhtmlx-gantt';
|
||||
|
||||
export const ganttTasks: Task[] = [
|
||||
{
|
||||
id: 11,
|
||||
text: 'CN-RDMS 架构设计',
|
||||
type: 'project',
|
||||
progress: 0,
|
||||
open: true,
|
||||
start_date: new Date('2024-01-10 00:00'),
|
||||
duration: 12,
|
||||
parent: 0
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
text: '测试版本',
|
||||
start_date: new Date('2024-03-20 00:00'),
|
||||
type: 'project',
|
||||
duration: 5,
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
text: '测试版本1 发布',
|
||||
start_date: new Date('2024-03-20 00:00'),
|
||||
end_date: new Date('2024-03-25 00:00'),
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 98,
|
||||
text: '测试版本2 发布',
|
||||
start_date: new Date('2024-03-26 00:00'),
|
||||
duration: 4,
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 97,
|
||||
text: '测试版本3 发布',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
duration: 10,
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
text: '1.0 版本',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
type: 'project',
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0.5,
|
||||
open: false,
|
||||
duration: 11
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
text: '1.0正式发布',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
end_date: new Date('2024-04-03 00:00'),
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
text: '1.0.1 版本',
|
||||
start_date: new Date('2024-04-03 00:00'),
|
||||
duration: 5,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
text: '1.0.2 版本',
|
||||
start_date: new Date('2024-04-08 00:00'),
|
||||
duration: 6,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
text: '1.0.3 版本',
|
||||
start_date: new Date('2024-04-16 00:00'),
|
||||
duration: 8,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
text: '1.0.4 版本',
|
||||
start_date: new Date('2024-04-17 00:00'),
|
||||
duration: 8,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
text: '1.0.5 版本',
|
||||
start_date: new Date('2024-04-26 00:00'),
|
||||
duration: 9,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
text: '1.0.9 版本',
|
||||
start_date: new Date('2024-05-05 00:00'),
|
||||
duration: 2,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
text: '1.1 版本',
|
||||
start_date: new Date('2024-05-07 00:00'),
|
||||
duration: 30,
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
text: '1.2 版本',
|
||||
start_date: new Date('2024-06-06 00:00'),
|
||||
duration: 46,
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
text: '1.3版本',
|
||||
type: 'project',
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true,
|
||||
start_date: new Date('2024-07-22 00:00'),
|
||||
duration: 11
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
text: '1.3.1版本',
|
||||
start_date: new Date('2024-07-22 00:00'),
|
||||
duration: 7,
|
||||
parent: '16',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
text: '1.3.2版本',
|
||||
start_date: new Date('2024-07-29 00:00'),
|
||||
duration: 7,
|
||||
parent: '16',
|
||||
progress: 0,
|
||||
open: true
|
||||
}
|
||||
];
|
||||
@@ -1,169 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { onMounted, shallowRef } from 'vue';
|
||||
import { gantt } from 'dhtmlx-gantt';
|
||||
import type { GanttConfigOptions, ZoomLevel } from 'dhtmlx-gantt';
|
||||
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
|
||||
import { ganttTasks } from './data';
|
||||
|
||||
defineOptions({ name: 'GanttPage' });
|
||||
|
||||
const ganttRef = shallowRef<HTMLElement>();
|
||||
|
||||
type TimeType = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
const timeType = shallowRef<TimeType>('quarter');
|
||||
|
||||
interface TimeData {
|
||||
label: string;
|
||||
value: TimeType;
|
||||
}
|
||||
|
||||
const data: TimeData[] = [
|
||||
{ label: '天', value: 'day' },
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '季', value: 'quarter' },
|
||||
{ label: '年', value: 'year' }
|
||||
];
|
||||
|
||||
function initGantt() {
|
||||
if (!ganttRef.value) return;
|
||||
|
||||
const config: Partial<GanttConfigOptions> = {
|
||||
grid_width: 350,
|
||||
add_column: false,
|
||||
autofit: false,
|
||||
row_height: 60,
|
||||
bar_height: 34,
|
||||
auto_types: true,
|
||||
xml_date: '%Y-%m-%d',
|
||||
columns: [
|
||||
{ name: 'text', label: '项目名称', tree: true, width: '*' },
|
||||
{ name: 'start_date', label: '开始时间', align: 'center', width: 150 }
|
||||
]
|
||||
};
|
||||
|
||||
Object.assign(gantt.config, config);
|
||||
|
||||
gantt.i18n.setLocale('cn');
|
||||
gantt.init(ganttRef.value);
|
||||
gantt.parse({ data: ganttTasks });
|
||||
|
||||
const zoomLevels: ZoomLevel[] = [
|
||||
{
|
||||
name: 'day',
|
||||
scale_height: 60,
|
||||
scales: [{ unit: 'day', step: 1, format: '%d %M' }]
|
||||
},
|
||||
{
|
||||
name: 'week',
|
||||
scale_height: 60,
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const dateToStr = gantt.date.date_to_str('%m-%d');
|
||||
const endDate = gantt.date.add(date, -6, 'day'); // 第几周
|
||||
return `${dateToStr(endDate)} 至 ${dateToStr(date)}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format: '%d',
|
||||
css(date: Date) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
return 'day-item weekend weekend-border-bottom';
|
||||
}
|
||||
return 'day-item';
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'month',
|
||||
scale_height: 60,
|
||||
min_column_width: 18,
|
||||
scales: [
|
||||
{ unit: 'month', format: '%Y-%m' },
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format: '%d',
|
||||
css(date: Date) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
return 'day-item weekend weekend-border-bottom';
|
||||
}
|
||||
return 'day-item';
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'quarter',
|
||||
height: 60,
|
||||
min_column_width: 110,
|
||||
scales: [
|
||||
{
|
||||
unit: 'quarter',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const yearStr = `${new Date(date).getFullYear()}年`;
|
||||
const dateToStr = gantt.date.date_to_str('%M');
|
||||
const endDate = gantt.date.add(gantt.date.add(date, 3, 'month'), -1, 'day');
|
||||
return `${yearStr + dateToStr(date)} - ${dateToStr(endDate)}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const dateToStr = gantt.date.date_to_str('%m-%d');
|
||||
const endDate = gantt.date.add(date, 6, 'day');
|
||||
return `${dateToStr(date)} 至 ${dateToStr(endDate)}`;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
scale_height: 50,
|
||||
min_column_width: 150,
|
||||
scales: [
|
||||
{ unit: 'year', step: 1, format: '%Y年' },
|
||||
{ unit: 'month', format: '%Y-%m' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
gantt.ext.zoom.init({ levels: zoomLevels });
|
||||
gantt.ext.zoom.setLevel(timeType.value);
|
||||
}
|
||||
|
||||
function changeTime(value: string | number) {
|
||||
timeType.value = value as TimeType;
|
||||
gantt.ext.zoom.setLevel(value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initGantt();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden lt-sm:overflow-auto">
|
||||
<ElCard header="甘特图演示" content-class="overflow-y-hidden overflow-x-auto" class="h-full card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>甘特图演示</p>
|
||||
<ElSegmented v-model="timeType" :options="data" @change="changeTime" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="ganttRef" class="size-full min-w-800px"></div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,721 +0,0 @@
|
||||
export const basicGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-15',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Feature Review',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-07-24',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-25',
|
||||
end: '2024-07-26',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Create',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-27',
|
||||
end: '2024-07-26',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Develop feature 1',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-01',
|
||||
end: '2024-08-15',
|
||||
progress: 0,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-01',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-01',
|
||||
end: '2024-08-01',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-30',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.26',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-16',
|
||||
end: '2024-07-18',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-09',
|
||||
end: '2024-09-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-23',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-30',
|
||||
end: '2024-08-14',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-04',
|
||||
end: '2024-08-04',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07/24/2024',
|
||||
end: '08/04/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-27',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-09',
|
||||
end: '2024-09-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.06',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const linkGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-15',
|
||||
end: '2024-07-16',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-16',
|
||||
end: '2024-07-17',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-18',
|
||||
end: '2024-07-19',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024/07/17',
|
||||
end: '2024/07/18',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07/19/2024',
|
||||
end: '07/20/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.06',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024/07/09',
|
||||
end: '2024/07/11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
|
||||
{
|
||||
id: 11,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-09',
|
||||
end: '2024-07-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
];
|
||||
|
||||
export const customGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Task 1',
|
||||
developer: 'bear.xiong',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/bear.jpg',
|
||||
start: '2024-07-24',
|
||||
end: '2024-07-26',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Task 2',
|
||||
developer: 'wolf.lang',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/wolf.jpg',
|
||||
start: '07/25/2024',
|
||||
end: '07/28/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Task 3',
|
||||
developer: 'rabbit.tu',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/rabbit.jpg',
|
||||
start: '2024-07-28',
|
||||
end: '2024-08-01',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Task 4',
|
||||
developer: 'cat.mao',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/cat.jpg',
|
||||
start: '2024-07-31',
|
||||
end: '2024-08-03',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Task 5',
|
||||
developer: 'bird.niao',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/bird.jpeg',
|
||||
start: '2024-08-02',
|
||||
end: '2024-08-04',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Task 6',
|
||||
developer: 'flower.hua',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/flower.jpg',
|
||||
start: '2024-08-03',
|
||||
end: '2024-08-10',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
];
|
||||
@@ -1,792 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import * as VTableGantt from '@visactor/vtable-gantt';
|
||||
import * as VTable_editors from '@visactor/vtable-editors';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { basicGanttRecords, customGanttRecords, linkGanttRecords } from './data';
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const input_editor = new VTable_editors.InputEditor();
|
||||
const date_input_editor = new VTable_editors.DateInputEditor();
|
||||
VTableGantt.VTable.register.editor('input', input_editor);
|
||||
VTableGantt.VTable.register.editor('date-input', date_input_editor);
|
||||
|
||||
const basicGanttDomRef = shallowRef<HTMLElement>();
|
||||
const linkGanttDomRef = shallowRef<HTMLElement>();
|
||||
const customGanttDomRef = shallowRef<HTMLElement>();
|
||||
|
||||
const basicGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
const linkGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
const customGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
|
||||
const basicGanttColumns = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'title',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
tree: true,
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'start',
|
||||
title: 'start',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'end',
|
||||
title: 'end',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'priority',
|
||||
title: 'priority',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: 'progress',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
headerStyle: {
|
||||
borderColor: '#e1e4e8'
|
||||
},
|
||||
style: {
|
||||
borderColor: '#e1e4e8',
|
||||
color: 'green'
|
||||
},
|
||||
editor: 'input'
|
||||
}
|
||||
];
|
||||
const basicGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
overscrollBehavior: 'none',
|
||||
records: basicGanttRecords,
|
||||
taskListTable: {
|
||||
columns: basicGanttColumns,
|
||||
tableWidth: 250,
|
||||
minTableWidth: 100,
|
||||
maxTableWidth: 600
|
||||
// rightFrozenColCount: 1
|
||||
},
|
||||
frame: {
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
borderColor: '#e1e4e8',
|
||||
cornerRadius: 8
|
||||
},
|
||||
verticalSplitLine: {
|
||||
lineColor: '#e1e4e8',
|
||||
lineWidth: 3
|
||||
},
|
||||
horizontalSplitLine: {
|
||||
lineColor: '#e1e4e8',
|
||||
lineWidth: 3
|
||||
},
|
||||
verticalSplitLineMoveable: true,
|
||||
verticalSplitLineHighlight: {
|
||||
lineColor: 'green',
|
||||
lineWidth: 3
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: 'gray',
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 40,
|
||||
rowHeight: 40,
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
// resizable: false,
|
||||
moveable: true,
|
||||
hoverBarStyle: {
|
||||
barOverlayColor: 'rgba(99, 144, 0, 0.4)'
|
||||
},
|
||||
labelText: '{title} {progress}%',
|
||||
labelTextStyle: {
|
||||
// padding: 2,
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 16,
|
||||
textAlign: 'left',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
barStyle: {
|
||||
width: 20,
|
||||
/** 任务条的颜色 */
|
||||
barColor: '#ee8800',
|
||||
/** 已完成部分任务条的颜色 */
|
||||
completedBarColor: '#91e8e0',
|
||||
/** 任务条的圆角 */
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
colWidth: 100,
|
||||
backgroundColor: '#EEF1F5',
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
startOfWeek: 'sunday',
|
||||
format(date: any) {
|
||||
return `Week ${date.dateIndex}`;
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
strokeColor: 'black',
|
||||
textAlign: 'right',
|
||||
textBaseline: 'bottom',
|
||||
textStick: true
|
||||
// padding: [0, 30, 0, 20]
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
strokeColor: 'black',
|
||||
textAlign: 'right',
|
||||
textBaseline: 'bottom'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
markLine: [
|
||||
{
|
||||
date: '2024-07-28',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
lineColor: 'blue',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
date: '2024-08-17',
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
lineColor: 'red',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
}
|
||||
],
|
||||
rowSeriesNumber: {
|
||||
title: '行号',
|
||||
dragOrder: true
|
||||
},
|
||||
scrollStyle: {
|
||||
scrollRailColor: 'RGBA(246,246,246,0.5)',
|
||||
visible: 'scrolling',
|
||||
width: 6,
|
||||
scrollSliderCornerRadius: 2,
|
||||
scrollSliderColor: '#5cb85c'
|
||||
}
|
||||
};
|
||||
|
||||
const linkGanttColumns = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'title',
|
||||
width: 'auto',
|
||||
tree: true
|
||||
},
|
||||
{
|
||||
field: 'start',
|
||||
title: 'start',
|
||||
width: 'auto',
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'end',
|
||||
title: 'end',
|
||||
width: 'auto',
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'priority',
|
||||
title: 'priority',
|
||||
width: 'auto',
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: 'progress',
|
||||
width: 'auto',
|
||||
headerStyle: {
|
||||
borderColor: '#e1e4e8'
|
||||
},
|
||||
style: {
|
||||
borderColor: '#e1e4e8',
|
||||
color: 'green'
|
||||
},
|
||||
editor: 'input'
|
||||
}
|
||||
];
|
||||
const linkGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
records: linkGanttRecords,
|
||||
taskListTable: {
|
||||
columns: linkGanttColumns,
|
||||
tableWidth: 400,
|
||||
minTableWidth: 100,
|
||||
maxTableWidth: 600
|
||||
},
|
||||
dependency: {
|
||||
links: [
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.FinishToStart,
|
||||
linkedFromTaskKey: 1,
|
||||
linkedToTaskKey: 2
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.StartToFinish,
|
||||
linkedFromTaskKey: 2,
|
||||
linkedToTaskKey: 3
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.StartToStart,
|
||||
linkedFromTaskKey: 3,
|
||||
linkedToTaskKey: 4
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.FinishToFinish,
|
||||
linkedFromTaskKey: 4,
|
||||
linkedToTaskKey: 5
|
||||
}
|
||||
],
|
||||
// linkSelectable: false,
|
||||
linkSelectedLineStyle: {
|
||||
shadowBlur: 5, // 阴影宽度
|
||||
shadowColor: 'red',
|
||||
lineColor: 'red',
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
frame: {
|
||||
verticalSplitLineMoveable: true,
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
// borderColor: 'red',
|
||||
cornerRadius: 8
|
||||
},
|
||||
verticalSplitLine: {
|
||||
lineWidth: 3,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
verticalSplitLineHighlight: {
|
||||
lineColor: 'green',
|
||||
lineWidth: 3
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: 'gray',
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 60,
|
||||
rowHeight: 40,
|
||||
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
labelText: '{title} {progress}%',
|
||||
labelTextStyle: {
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 16,
|
||||
textAlign: 'left'
|
||||
},
|
||||
barStyle: {
|
||||
width: 20,
|
||||
/** 任务条的颜色 */
|
||||
barColor: '#ee8800',
|
||||
/** 已完成部分任务条的颜色 */
|
||||
completedBarColor: '#91e8e0',
|
||||
/** 任务条的圆角 */
|
||||
cornerRadius: 10
|
||||
},
|
||||
selectedBarStyle: {
|
||||
shadowBlur: 5, // 阴影宽度
|
||||
shadowOffsetX: 0, // x方向偏移
|
||||
shadowOffsetY: 0, // Y方向偏移
|
||||
shadowColor: 'black', // 阴影颜色
|
||||
borderColor: 'red', // 边框颜色
|
||||
borderLineWidth: 1 // 边框宽度
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
backgroundColor: '#EEF1F5',
|
||||
colWidth: 60,
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
startOfWeek: 'sunday',
|
||||
format(date: any) {
|
||||
return `Week ${date.dateIndex}`;
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'red'
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'red'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
minDate: '2024-07-14',
|
||||
maxDate: '2024-10-15',
|
||||
|
||||
rowSeriesNumber: {
|
||||
title: '行号',
|
||||
dragOrder: true
|
||||
},
|
||||
scrollStyle: {
|
||||
visible: 'scrolling'
|
||||
},
|
||||
overscrollBehavior: 'none'
|
||||
};
|
||||
|
||||
const barColors0 = ['#aecde6', '#c6a49a', '#ffb582', '#eec1de', '#b3d9b3', '#cccccc', '#e59a9c', '#d9d1a5', '#c9bede'];
|
||||
const barColors = ['#1f77b4', '#8c564b', '#ff7f0e', '#e377c2', '#2ca02c', '#7f7f7f', '#d62728', '#bcbd22', '#9467bd'];
|
||||
const customGanttColumns: VTableGantt.ColumnsDefine = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'TASK',
|
||||
width: '200',
|
||||
headerStyle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold'
|
||||
// color: 'black',
|
||||
// bgColor: '#f0f0fb'
|
||||
},
|
||||
style: {
|
||||
// bgColor: '#f0f0fb'
|
||||
},
|
||||
customLayout: (args: any) => {
|
||||
const { table, row, col, rect } = args;
|
||||
const taskRecord = table.getCellOriginRecord(col, row);
|
||||
const { height, width } = rect ?? table.getCellRect(col, row);
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
y: 10,
|
||||
x: 20,
|
||||
height: height - 20,
|
||||
width: width - 40,
|
||||
fill: '#ddd',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cornerRadius: 30
|
||||
});
|
||||
|
||||
const developer = new VTableGantt.VRender.Text({
|
||||
text: taskRecord.developer,
|
||||
fontSize: 16,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: barColors[args.row],
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: width - 120,
|
||||
boundsPadding: [10, 0, 0, 0],
|
||||
alignSelf: 'center'
|
||||
});
|
||||
container.add(developer);
|
||||
|
||||
const days = new VTableGantt.VRender.Text({
|
||||
text: `${VTableGantt.tools.formatDate(new Date(taskRecord.start), 'mm/dd')}-${VTableGantt.tools.formatDate(
|
||||
new Date(taskRecord.end),
|
||||
'mm/dd'
|
||||
)}`,
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
fill: 'black',
|
||||
boundsPadding: [10, 0, 0, 0],
|
||||
alignSelf: 'center'
|
||||
});
|
||||
container.add(days);
|
||||
|
||||
return {
|
||||
rootContainer: container,
|
||||
expectedWidth: 160
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
const customGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
records: customGanttRecords,
|
||||
taskListTable: {
|
||||
columns: customGanttColumns,
|
||||
tableWidth: 'auto'
|
||||
},
|
||||
frame: {
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
borderColor: '#E1E4E8',
|
||||
cornerRadius: 8
|
||||
}
|
||||
// verticalSplitLineHighlight: {
|
||||
// lineColor: 'green',
|
||||
// lineWidth: 3
|
||||
// }
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: '#f0f0fb',
|
||||
// vertical: {
|
||||
// lineWidth: 1,
|
||||
// lineColor: '#e1e4e8'
|
||||
// },
|
||||
horizontalLine: {
|
||||
lineWidth: 2,
|
||||
lineColor: '#d5d9ee'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 60,
|
||||
rowHeight: 80,
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
barStyle: { width: 60 },
|
||||
customLayout: (args: any) => {
|
||||
const colorLength = barColors.length;
|
||||
const { width, height, index, taskDays, progress, taskRecord } = args;
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
width,
|
||||
height,
|
||||
cornerRadius: 30,
|
||||
fill: {
|
||||
gradient: 'linear',
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 1,
|
||||
y1: 0,
|
||||
stops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: barColors0[index % colorLength]
|
||||
},
|
||||
{
|
||||
offset: 0.5,
|
||||
color: barColors[index % colorLength]
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: barColors0[index % colorLength]
|
||||
}
|
||||
]
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap'
|
||||
});
|
||||
const containerLeft = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around'
|
||||
// fill: 'red'
|
||||
});
|
||||
container.add(containerLeft as any);
|
||||
|
||||
const avatar = new VTableGantt.VRender.Image({
|
||||
width: 50,
|
||||
height: 50,
|
||||
image: taskRecord.avatar,
|
||||
cornerRadius: 25
|
||||
});
|
||||
containerLeft.add(avatar);
|
||||
const containerCenter = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: width - 120,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
// alignItems: 'left'
|
||||
});
|
||||
container.add(containerCenter as any);
|
||||
|
||||
const developer = new VTableGantt.VRender.Text({
|
||||
text: taskRecord.developer,
|
||||
fontSize: 16,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'white',
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: width - 120,
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(developer);
|
||||
|
||||
const days = new VTableGantt.VRender.Text({
|
||||
text: `${taskDays}天`,
|
||||
fontSize: 13,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'white',
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(days);
|
||||
|
||||
if (width >= 120) {
|
||||
const containerRight = new VTableGantt.VRender.Group({
|
||||
cornerRadius: 20,
|
||||
fill: 'white',
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center', // 垂直方向居中对齐
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
container.add(containerRight as any);
|
||||
|
||||
const progressText = new VTableGantt.VRender.Text({
|
||||
text: `${progress}%`,
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'black',
|
||||
alignSelf: 'center',
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: (width - 60) / 2,
|
||||
boundsPadding: [0, 0, 0, 0]
|
||||
});
|
||||
containerRight.add(progressText);
|
||||
}
|
||||
return {
|
||||
rootContainer: container
|
||||
// renderDefaultBar: true
|
||||
// renderDefaultText: true
|
||||
};
|
||||
},
|
||||
hoverBarStyle: {
|
||||
cornerRadius: 30
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
backgroundColor: '#f0f0fb',
|
||||
colWidth: 80,
|
||||
// verticalLine: {
|
||||
// lineColor: 'red',
|
||||
// lineWidth: 1,
|
||||
// lineDash: [4, 2]
|
||||
// },
|
||||
// horizontalLine: {
|
||||
// lineColor: 'green',
|
||||
// lineWidth: 1,
|
||||
// lineDash: [4, 2]
|
||||
// },
|
||||
scales: [
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
customLayout: (args: any) => {
|
||||
const { width, height, startDate, dateIndex } = args;
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
width,
|
||||
height,
|
||||
// fill: '#f0f0fb',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap'
|
||||
});
|
||||
const containerLeft = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: 30,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around'
|
||||
// fill: 'red'
|
||||
});
|
||||
container.add(containerLeft as any);
|
||||
|
||||
const avatar = new VTableGantt.VRender.Image({
|
||||
width: 20,
|
||||
height: 30,
|
||||
image:
|
||||
'<svg t="1724675965803" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4299" width="200" height="200"><path d="M53.085678 141.319468C23.790257 141.319468 0 165.035326 0 194.34775L0 918.084273C0 947.295126 23.796789 971.112572 53.085678 971.112572L970.914322 971.112572C1000.209743 971.112572 1024 947.396696 1024 918.084273L1024 194.34775C1024 165.136896 1000.203211 141.319468 970.914322 141.319468L776.827586 141.319468 812.137931 176.629813 812.137931 88.275862C812.137931 68.774506 796.328942 52.965517 776.827586 52.965517 757.32623 52.965517 741.517241 68.774506 741.517241 88.275862L741.517241 176.629813 741.517241 211.940158 776.827586 211.940158 970.914322 211.940158C961.186763 211.940158 953.37931 204.125926 953.37931 194.34775L953.37931 918.084273C953.37931 908.344373 961.25643 900.491882 970.914322 900.491882L53.085678 900.491882C62.813237 900.491882 70.62069 908.306097 70.62069 918.084273L70.62069 194.34775C70.62069 204.087649 62.74357 211.940158 53.085678 211.940158L247.172414 211.940158C266.67377 211.940158 282.482759 196.131169 282.482759 176.629813 282.482759 157.128439 266.67377 141.319468 247.172414 141.319468L53.085678 141.319468ZM211.862069 176.629813C211.862069 196.131169 227.671058 211.940158 247.172414 211.940158 266.67377 211.940158 282.482759 196.131169 282.482759 176.629813L282.482759 88.275862C282.482759 68.774506 266.67377 52.965517 247.172414 52.965517 227.671058 52.965517 211.862069 68.774506 211.862069 88.275862L211.862069 176.629813ZM1024 353.181537 1024 317.871192 988.689655 317.871192 35.310345 317.871192 0 317.871192 0 353.181537 0 441.457399C0 460.958755 15.808989 476.767744 35.310345 476.767744 54.811701 476.767744 70.62069 460.958755 70.62069 441.457399L70.62069 353.181537 35.310345 388.491882 988.689655 388.491882 953.37931 353.181537 953.37931 441.457399C953.37931 460.958755 969.188299 476.767744 988.689655 476.767744 1008.191011 476.767744 1024 460.958755 1024 441.457399L1024 353.181537ZM776.937913 582.62069C796.439287 582.62069 812.248258 566.811701 812.248258 547.310345 812.248258 527.808989 796.439287 512 776.937913 512L247.172414 512C227.671058 512 211.862069 527.808989 211.862069 547.310345 211.862069 566.811701 227.671058 582.62069 247.172414 582.62069L776.937913 582.62069ZM247.172414 688.551724C227.671058 688.551724 211.862069 704.360713 211.862069 723.862069 211.862069 743.363425 227.671058 759.172414 247.172414 759.172414L600.386189 759.172414C619.887563 759.172414 635.696534 743.363425 635.696534 723.862069 635.696534 704.360713 619.887563 688.551724 600.386189 688.551724L247.172414 688.551724ZM776.827586 211.940158 741.517241 176.629813 741.517241 247.328574C741.517241 266.829948 757.32623 282.638919 776.827586 282.638919 796.328942 282.638919 812.137931 266.829948 812.137931 247.328574L812.137931 176.629813 812.137931 141.319468 776.827586 141.319468 247.172414 141.319468C227.671058 141.319468 211.862069 157.128439 211.862069 176.629813 211.862069 196.131169 227.671058 211.940158 247.172414 211.940158L776.827586 211.940158ZM282.482759 176.629813C282.482759 157.128439 266.67377 141.319468 247.172414 141.319468 227.671058 141.319468 211.862069 157.128439 211.862069 176.629813L211.862069 247.328574C211.862069 266.829948 227.671058 282.638919 247.172414 282.638919 266.67377 282.638919 282.482759 266.829948 282.482759 247.328574L282.482759 176.629813Z" fill="#389BFF" p-id="4300"></path></svg>'
|
||||
});
|
||||
containerLeft.add(avatar);
|
||||
|
||||
const containerCenter = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: width - 30,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
// alignItems: 'left'
|
||||
});
|
||||
container.add(containerCenter as any);
|
||||
const dayNumber = new VTableGantt.VRender.Text({
|
||||
text: String(dateIndex).padStart(2, '0'),
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#777',
|
||||
textAlign: 'right',
|
||||
maxLineWidth: width - 30,
|
||||
boundsPadding: [15, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(dayNumber);
|
||||
|
||||
const weekDay = new VTableGantt.VRender.Text({
|
||||
text: VTableGantt.tools.getWeekday(startDate, 'short').toLocaleUpperCase(),
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#777',
|
||||
boundsPadding: [0, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(weekDay);
|
||||
return {
|
||||
rootContainer: container
|
||||
// renderDefaultText: true
|
||||
};
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
minDate: '2024-07-20',
|
||||
maxDate: '2024-08-15',
|
||||
markLine: [
|
||||
{
|
||||
date: '2024-07-29',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
lineColor: 'blue',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
date: '2024-08-17',
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
lineColor: 'red',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
}
|
||||
],
|
||||
scrollStyle: {
|
||||
scrollRailColor: 'RGBA(246,246,246,0.5)',
|
||||
visible: 'focus',
|
||||
width: 6,
|
||||
scrollSliderCornerRadius: 2,
|
||||
scrollSliderColor: '#5cb85c'
|
||||
}
|
||||
};
|
||||
|
||||
function initVTableGantt() {
|
||||
basicGanttInstance.value = new VTableGantt.Gantt(basicGanttDomRef.value as HTMLElement, getOption(basicGanttOption));
|
||||
linkGanttInstance.value = new VTableGantt.Gantt(linkGanttDomRef.value as HTMLElement, getOption(linkGanttOption));
|
||||
customGanttInstance.value = new VTableGantt.Gantt(
|
||||
customGanttDomRef.value as HTMLElement,
|
||||
getOption(customGanttOption)
|
||||
);
|
||||
}
|
||||
|
||||
function getOption(option: VTableGantt.GanttConstructorOptions) {
|
||||
const isDark = theme.darkMode;
|
||||
if (isDark) {
|
||||
option.taskListTable!.theme = VTableGantt.VTable.themes.DARK;
|
||||
option.timelineHeader.backgroundColor = '#212121';
|
||||
option.underlayBackgroundColor = '#000';
|
||||
} else {
|
||||
option.taskListTable!.theme = VTableGantt.VTable.themes.DEFAULT;
|
||||
option.timelineHeader.backgroundColor = '#f0f0fb';
|
||||
option.underlayBackgroundColor = '#fff';
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => theme.darkMode,
|
||||
_newValue => {
|
||||
basicGanttInstance.value?.release();
|
||||
linkGanttInstance.value?.release();
|
||||
customGanttInstance.value?.release();
|
||||
|
||||
initVTableGantt();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initVTableGantt();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard header="VTableGantt" class="h-full card-wrapper">
|
||||
<WebSiteLink label="More Demos: " link="https://www.visactor.com/vtable/example" />
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="basicGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="linkGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="customGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
export const icons = [
|
||||
'mdi:emoticon',
|
||||
'mdi:ab-testing',
|
||||
'ph:alarm',
|
||||
'ph:android-logo',
|
||||
'ph:align-bottom',
|
||||
'ph:archive-box-light',
|
||||
'uil:basketball',
|
||||
'uil:brightness-plus',
|
||||
'uil:capture',
|
||||
'mdi:apps-box',
|
||||
'mdi:alert',
|
||||
'mdi:airballoon',
|
||||
'mdi:airplane-edit',
|
||||
'mdi:alpha-f-box-outline',
|
||||
'mdi:arm-flex-outline',
|
||||
'ic:baseline-10mp',
|
||||
'ic:baseline-access-time',
|
||||
'ic:baseline-brightness-4',
|
||||
'ic:baseline-brightness-5',
|
||||
'ic:baseline-credit-card',
|
||||
'ic:baseline-filter-1',
|
||||
'ic:baseline-filter-2',
|
||||
'ic:baseline-filter-3',
|
||||
'ic:baseline-filter-4',
|
||||
'ic:baseline-filter-5',
|
||||
'ic:baseline-filter-6',
|
||||
'ic:baseline-filter-7',
|
||||
'ic:baseline-filter-8',
|
||||
'ic:baseline-filter-9',
|
||||
'ic:baseline-filter-9-plus'
|
||||
];
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { icons } from './icons';
|
||||
|
||||
defineOptions({ name: 'IconPage' });
|
||||
|
||||
const selectValue = ref('');
|
||||
|
||||
const localIcons = ['custom-icon', 'activity', 'at-sign', 'cast', 'chrome', 'copy', 'wind'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="Icon组件示例" class="card-wrapper">
|
||||
<div class="grid grid-cols-10">
|
||||
<template v-for="item in icons" :key="item">
|
||||
<div class="mt-5px flex-x-center">
|
||||
<SvgIcon :icon="item" class="text-30px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-50px">
|
||||
<h1 class="mb-20px text-18px font-500">Icon图标选择器</h1>
|
||||
<CustomIconSelect v-model:value="selectValue" :icons="icons" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<WebSiteLink label="iconify地址:" link="https://icones.js.org/" class="mt-10px" />
|
||||
</template>
|
||||
</ElCard>
|
||||
<ElCard header="自定义图标示例" class="mt-10px card-wrapper">
|
||||
<div class="pb-12px text-16px">
|
||||
在src/assets/svg-icon文件夹下的svg文件,通过在template里面以 icon-local-{文件名} 直接渲染,
|
||||
其中icon-local为.env文件里的 VITE_ICON_LOCAL_PREFIX
|
||||
</div>
|
||||
<div class="grid grid-cols-10">
|
||||
<div class="mt-5px flex-x-center">
|
||||
<icon-local-activity class="text-40px text-success" />
|
||||
</div>
|
||||
<div class="mt-5px flex-x-center">
|
||||
<icon-local-cast class="text-20px text-error" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-12px text-16px">通过SvgIcon组件动态渲染, 菜单通过meta的localIcon属性渲染自定义图标</div>
|
||||
<div class="grid grid-cols-10">
|
||||
<div v-for="(fileName, index) in localIcons" :key="index" class="mt-5px flex-x-center">
|
||||
<SvgIcon :local-icon="fileName" class="text-30px text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user