5 Commits

Author SHA1 Message Date
2e369b23a9 refactor(projects): 删除废弃代码 2026-06-05 16:29:35 +08:00
b72ad00912 fix(error-message): 删除用户可见错误文案规范HTML文档
- 移除了完整的用户可见错误文案规范HTML文件
2026-06-04 21:07:44 +08:00
7cc29e0a35 fix(projects): 针对技术负债去优化代码 2026-06-04 21:06:05 +08:00
39458386ae feat(projects): 工作台部分组件调成真实数据 2026-06-04 11:26:51 +08:00
dk
acef4418d8 fix(加班申请): 使用后端专门返回状态的接口,代替使用字典。
fix(status-tag.ts):把产品需求、项目需求的状态颜色定义收敛到此处。
2026-06-04 10:49:34 +08:00
103 changed files with 1178 additions and 10551 deletions

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-01T01:55:51.875Z", "generatedAt": "2026-06-05T03:08:01.803Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",
@@ -437,39 +437,6 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "待我审批",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 5,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "待我审批",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 5,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "personal-center_overtime-application", "name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application", "path": "/personal-center/overtime-application",
@@ -503,6 +470,39 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "待我审批",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "待我审批",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 7,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "system_user", "name": "system_user",
"path": "/system/user", "path": "/system/user",

View File

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

49
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
element-plus: element-plus:
specifier: ^2.11.1 specifier: ^2.11.1
version: 2.13.6(typescript@5.8.3)(vue@3.5.20(typescript@5.8.3)) 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: jsbarcode:
specifier: 3.12.1 specifier: 3.12.1
version: 3.12.1 version: 3.12.1
@@ -882,6 +885,9 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3' vue: '>=3'
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@intlify/core-base@11.1.11': '@intlify/core-base@11.1.11':
resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==} resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@@ -921,6 +927,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@naoak/workerize-transferable@0.1.0':
resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==} resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==}
peerDependencies: peerDependencies:
@@ -1817,6 +1826,14 @@ packages:
peerDependencies: peerDependencies:
'@uppy/core': ^2.3.3 '@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': '@visactor/vchart-theme@1.12.2':
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==} resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
peerDependencies: peerDependencies:
@@ -3493,6 +3510,11 @@ packages:
graphlib@2.1.8: graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} 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: gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3629,6 +3651,9 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0: internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6226,6 +6251,8 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
vue: 3.5.20(typescript@5.8.3) vue: 3.5.20(typescript@5.8.3)
'@interactjs/types@1.10.27': {}
'@intlify/core-base@11.1.11': '@intlify/core-base@11.1.11':
dependencies: dependencies:
'@intlify/message-compiler': 11.1.11 '@intlify/message-compiler': 11.1.11
@@ -6273,6 +6300,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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))': '@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.105.4))':
dependencies: dependencies:
workerize-loader: 2.0.2(webpack@5.105.4) workerize-loader: 2.0.2(webpack@5.105.4)
@@ -7082,6 +7111,15 @@ snapshots:
'@uppy/utils': 4.1.3 '@uppy/utils': 4.1.3
nanoid: 3.3.11 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)': '@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
dependencies: dependencies:
'@visactor/vchart': 2.0.4 '@visactor/vchart': 2.0.4
@@ -9179,6 +9217,13 @@ snapshots:
dependencies: dependencies:
lodash: 4.17.23 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: gzip-size@6.0.0:
dependencies: dependencies:
duplexer: 0.1.2 duplexer: 0.1.2
@@ -9295,6 +9340,10 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0

View File

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

View File

@@ -112,14 +112,6 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
*/ */
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty'; export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班申请状态字典编码
*
* 对应业务字段:加班申请中的 statusCode
* 来源口径:`overtime-application-design.md` 明确状态字典为 rdms_overtime_application_status
*/
export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_application_status';
/** /**
* 加班时长快捷选项字典编码 * 加班时长快捷选项字典编码
* *

View File

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

View File

@@ -14,7 +14,8 @@ export type StatusDomain =
| 'taskAssigneeMember' | 'taskAssigneeMember'
| 'project' | 'project'
| 'product' | 'product'
| 'requirement' | 'productRequirement'
| 'projectRequirement'
| 'workOrder' | 'workOrder'
| 'personalItem' | 'personalItem'
| 'overtimeApplication'; | 'overtimeApplication';
@@ -52,8 +53,31 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
project: {}, project: {},
// 产品(待补全) // 产品(待补全)
product: {}, product: {},
// 需求(待补全) // 产品需求
requirement: {}, productRequirement: {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 项目需求
projectRequirement: {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 工单(待补全) // 工单(待补全)
workOrder: {}, workOrder: {},
// 个人事项 // 个人事项
@@ -83,7 +107,3 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) { export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('personalItem', statusCode); return getStatusTagType('personalItem', statusCode);
} }
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('overtimeApplication', statusCode);
}

View File

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

View File

@@ -178,16 +178,6 @@ const local: App.I18n.Schema = {
infra: 'Infra', infra: 'Infra',
'infra_state-machine': 'State Machine', 'infra_state-machine': 'State Machine',
'infra_rd-code': 'R&D Code', 'infra_rd-code': 'R&D Code',
function: 'System Function',
function_tab: 'Tab',
'function_multi-tab': 'Multi Tab',
'function_hide-child': 'Hide Child',
'function_hide-child_one': 'Hide Child',
'function_hide-child_two': 'Two',
'function_hide-child_three': 'Three',
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
product: 'Product', product: 'Product',
product_list: 'Product List', product_list: 'Product List',
product_dashboard: 'Dashboard', product_dashboard: 'Dashboard',
@@ -211,28 +201,7 @@ const local: App.I18n.Schema = {
exception: 'Exception', exception: 'Exception',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: 'Plugin',
plugin_copy: 'Copy',
plugin_charts: 'Charts',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: 'Icon',
plugin_map: 'Map',
plugin_print: 'Print',
plugin_swiper: 'Swiper',
plugin_video: 'Video',
plugin_barcode: 'Barcode',
plugin_pinyin: 'pinyin',
plugin_excel: 'Excel',
plugin_pdf: 'PDF preview',
plugin_gantt: 'Gantt Chart',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: 'Typeit',
plugin_tables: 'Tables',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -328,45 +297,6 @@ const local: App.I18n.Schema = {
}, },
creativity: 'Creativity' creativity: 'Creativity'
}, },
function: {
tab: {
tabOperate: {
title: 'Tab Operation',
addTab: 'Add Tab',
addTabDesc: 'To user management page',
closeTab: 'Close Tab',
closeCurrentTab: 'Close Current Tab',
closeAboutTab: 'Close "User Management" Tab',
addMultiTab: 'Add Multi Tab',
addMultiTabDesc1: 'To MultiTab page',
addMultiTabDesc2: 'To MultiTab page(with query params)'
},
tabTitle: {
title: 'Tab Title',
changeTitle: 'Change Title',
change: 'Change',
resetTitle: 'Reset Title',
reset: 'Reset'
}
},
multiTab: {
routeParam: 'Route Param',
backTab: 'Back function_tab'
},
toggleAuth: {
toggleAccount: 'Toggle Account',
authHook: 'Auth Hook Function `hasAuth`',
superAdminVisible: 'Super Admin Visible',
adminVisible: 'Admin Visible',
adminOrUserVisible: 'Admin and User Visible'
},
request: {
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
repeatedError: 'Repeated Request Error',
repeatedErrorMsg1: 'Custom Request Error 1',
repeatedErrorMsg2: 'Custom Request Error 2'
}
},
system: { system: {
common: { common: {
status: { status: {

View File

@@ -178,16 +178,6 @@ const local: App.I18n.Schema = {
infra: '基础设施', infra: '基础设施',
'infra_state-machine': '状态机管理', 'infra_state-machine': '状态机管理',
'infra_rd-code': '研发令号', 'infra_rd-code': '研发令号',
function: '系统功能',
function_tab: '标签页',
'function_multi-tab': '多标签页',
'function_hide-child': '隐藏子菜单',
'function_hide-child_one': '隐藏子菜单',
'function_hide-child_two': '菜单二',
'function_hide-child_three': '菜单三',
function_request: '请求',
'function_toggle-auth': '切换权限',
'function_super-page': '超级管理员可见',
product: '产品管理', product: '产品管理',
product_list: '产品列表', product_list: '产品列表',
product_dashboard: '产品仪表盘', product_dashboard: '产品仪表盘',
@@ -211,28 +201,7 @@ const local: App.I18n.Schema = {
exception: '异常页', exception: '异常页',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: '插件示例',
plugin_copy: '剪贴板',
plugin_charts: '图表',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: '图标',
plugin_map: '地图',
plugin_print: '打印',
plugin_swiper: 'Swiper',
plugin_video: '视频',
plugin_barcode: '条形码',
plugin_pinyin: '拼音',
plugin_excel: 'Excel',
plugin_pdf: 'PDF 预览',
plugin_gantt: '甘特图',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: '打字机',
plugin_tables: '表格',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -327,45 +296,6 @@ const local: App.I18n.Schema = {
}, },
creativity: '创意' creativity: '创意'
}, },
function: {
tab: {
tabOperate: {
title: '标签页操作',
addTab: '添加标签页',
addTabDesc: '跳转到用户管理页面',
closeTab: '关闭标签页',
closeCurrentTab: '关闭当前标签页',
closeAboutTab: '关闭"用户管理"标签页',
addMultiTab: '添加多标签页',
addMultiTabDesc1: '跳转到多标签页页面',
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
},
tabTitle: {
title: '标签页标题',
changeTitle: '修改标题',
change: '修改',
resetTitle: '重置标题',
reset: '重置'
}
},
multiTab: {
routeParam: '路由参数',
backTab: '返回 function_tab'
},
toggleAuth: {
toggleAccount: '切换账号',
authHook: '权限钩子函数 `hasAuth`',
superAdminVisible: '超级管理员可见',
adminVisible: '管理员可见',
adminOrUserVisible: '管理员和用户可见'
},
request: {
repeatedErrorOccurOnce: '重复请求错误只出现一次',
repeatedError: '重复请求错误',
repeatedErrorMsg1: '自定义请求错误 1',
repeatedErrorMsg2: '自定义请求错误 2'
}
},
system: { system: {
common: { common: {
status: { status: {

View File

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

View File

@@ -20,14 +20,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"), 500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"), "iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"), login: () => import("@/views/_builtin/login/index.vue"),
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"),
function_request: () => import("@/views/function/request/index.vue"),
"function_super-page": () => import("@/views/function/super-page/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"), "infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"), "infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"), "metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
@@ -41,23 +33,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/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_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"), "personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
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_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"), product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"), product_requirement: () => import("@/views/product/requirement/index.vue"),

View File

@@ -39,124 +39,6 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true hideInMenu: true
} }
}, },
{
name: 'function',
path: '/function',
component: 'layout.base',
meta: {
title: 'function',
i18nKey: 'route.function',
icon: 'icon-park-outline:all-application',
order: 6
},
children: [
{
name: 'function_hide-child',
path: '/function/hide-child',
meta: {
title: 'function_hide-child',
i18nKey: 'route.function_hide-child',
icon: 'material-symbols:filter-list-off',
order: 2
},
redirect: '/function/hide-child/one',
children: [
{
name: 'function_hide-child_one',
path: '/function/hide-child/one',
component: 'view.function_hide-child_one',
meta: {
title: 'function_hide-child_one',
i18nKey: 'route.function_hide-child_one',
icon: 'material-symbols:filter-list-off',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_three',
path: '/function/hide-child/three',
component: 'view.function_hide-child_three',
meta: {
title: 'function_hide-child_three',
i18nKey: 'route.function_hide-child_three',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
},
{
name: 'function_hide-child_two',
path: '/function/hide-child/two',
component: 'view.function_hide-child_two',
meta: {
title: 'function_hide-child_two',
i18nKey: 'route.function_hide-child_two',
hideInMenu: true,
activeMenu: 'function_hide-child'
}
}
]
},
{
name: 'function_multi-tab',
path: '/function/multi-tab',
component: 'view.function_multi-tab',
meta: {
title: 'function_multi-tab',
i18nKey: 'route.function_multi-tab',
icon: 'ic:round-tab',
multiTab: true,
hideInMenu: true,
activeMenu: 'function_tab'
}
},
{
name: 'function_request',
path: '/function/request',
component: 'view.function_request',
meta: {
title: 'function_request',
i18nKey: 'route.function_request',
icon: 'carbon:network-overlay',
order: 3
}
},
{
name: 'function_super-page',
path: '/function/super-page',
component: 'view.function_super-page',
meta: {
title: 'function_super-page',
i18nKey: 'route.function_super-page',
icon: 'ic:round-supervisor-account',
order: 5,
roles: ['R_SUPER']
}
},
{
name: 'function_tab',
path: '/function/tab',
component: 'view.function_tab',
meta: {
title: 'function_tab',
i18nKey: 'route.function_tab',
icon: 'ic:round-tab',
order: 1
}
},
{
name: 'function_toggle-auth',
path: '/function/toggle-auth',
component: 'view.function_toggle-auth',
meta: {
title: 'function_toggle-auth',
i18nKey: 'route.function_toggle-auth',
icon: 'ic:round-construction',
order: 4
}
}
]
},
{ {
name: 'iframe-page', name: 'iframe-page',
path: '/iframe-page/:url', path: '/iframe-page/:url',
@@ -377,223 +259,6 @@ export const generatedRoutes: GeneratedRoute[] = [
} }
] ]
}, },
{
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', name: 'product',
path: '/product', path: '/product',

View File

@@ -170,16 +170,6 @@ const routeMap: RouteMap = {
"403": "/403", "403": "/403",
"404": "/404", "404": "/404",
"500": "/500", "500": "/500",
"function": "/function",
"function_hide-child": "/function/hide-child",
"function_hide-child_one": "/function/hide-child/one",
"function_hide-child_three": "/function/hide-child/three",
"function_hide-child_two": "/function/hide-child/two",
"function_multi-tab": "/function/multi-tab",
"function_request": "/function/request",
"function_super-page": "/function/super-page",
"function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth",
"iframe-page": "/iframe-page/:url", "iframe-page": "/iframe-page/:url",
"infra": "/infra", "infra": "/infra",
"infra_rd-code": "/infra/rd-code", "infra_rd-code": "/infra/rd-code",
@@ -198,27 +188,6 @@ const routeMap: RouteMap = {
"personal-center_my-weekly": "/personal-center/my-weekly", "personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_overtime-application": "/personal-center/overtime-application", "personal-center_overtime-application": "/personal-center/overtime-application",
"personal-center_pending-approval": "/personal-center/pending-approval", "personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin",
"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": "/product",
"product_dashboard": "/product/dashboard", "product_dashboard": "/product/dashboard",
"product_list": "/product/list", "product_list": "/product/list",

View File

@@ -269,6 +269,19 @@ export async function fetchGetOvertimeApplicationStatusLogs(id: string) {
); );
} }
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 = {}) { export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params); const query = createPageQuery(params);

View File

@@ -40,6 +40,42 @@ export type ProjectExecutionResponse = Omit<
priorityName?: string | 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'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
@@ -286,6 +322,50 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
}; };
} }
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 { export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,

View File

@@ -10,6 +10,9 @@ import {
import { import {
type ExecutionAssigneeLogResponse, type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse, type ExecutionAssigneeResponse,
type MyExecutionResponse,
type MyOwnedProjectResponse,
type MyParticipatedProjectResponse,
type ProjectExecutionResponse, type ProjectExecutionResponse,
type ProjectLocalDateValue, type ProjectLocalDateValue,
type ProjectMemberResponse, type ProjectMemberResponse,
@@ -20,6 +23,9 @@ import {
getProjectLifecycleActions, getProjectLifecycleActions,
normalizeExecutionAssignee, normalizeExecutionAssignee,
normalizeExecutionAssigneeLog, normalizeExecutionAssigneeLog,
normalizeMyExecution,
normalizeMyOwnedProject,
normalizeMyParticipatedProject,
normalizeProjectExecution, normalizeProjectExecution,
normalizeProjectLocalDate, normalizeProjectLocalDate,
normalizeProjectMember, 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( export function fetchGetProjectExecutionStatusBoard(
projectId: string, projectId: string,

View File

@@ -74,5 +74,14 @@ declare namespace Api {
remark?: string | null; remark?: string | null;
createTime: string; createTime: string;
} }
interface OvertimeApplicationStatusDict {
statusCode: string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
} }
} }

View File

@@ -304,6 +304,107 @@ declare namespace Api {
updateTime: string[]; updateTime: string[];
}>; }>;
/** 工作台「我负责的执行」(跨项目)查询入参 */
type MyExecutionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 预留:单状态精确过滤,不传走后端默认口径 */
statusCode: string;
/** 预留:执行名称模糊匹配 */
keyword: string;
}
>;
/** 工作台「我负责的执行」单项跨项目聚合owner 恒为当前登录用户) */
interface MyExecutionItem {
/** 执行 ID雪花 ID字符串 */
id: string;
executionName: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 执行状态编码pending / active / paused */
statusCode: string;
/** 执行状态名称 */
statusName: string | null;
/** 优先级字典 valuerdms_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 */ /** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams { interface CreateProjectExecutionParams {
executionName: string; executionName: string;

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

@@ -504,45 +504,6 @@ declare namespace App {
}; };
creativity: string; creativity: string;
}; };
function: {
tab: {
tabOperate: {
title: string;
addTab: string;
addTabDesc: string;
closeTab: string;
closeCurrentTab: string;
closeAboutTab: string;
addMultiTab: string;
addMultiTabDesc1: string;
addMultiTabDesc2: string;
};
tabTitle: {
title: string;
changeTitle: string;
change: string;
resetTitle: string;
reset: string;
};
};
multiTab: {
routeParam: string;
backTab: string;
};
toggleAuth: {
toggleAccount: string;
authHook: string;
superAdminVisible: string;
adminVisible: string;
adminOrUserVisible: string;
};
request: {
repeatedErrorOccurOnce: string;
repeatedError: string;
repeatedErrorMsg1: string;
repeatedErrorMsg2: string;
};
};
system: { system: {
common: { common: {
status: { status: {

View File

@@ -24,16 +24,6 @@ declare module "@elegant-router/types" {
"403": "/403"; "403": "/403";
"404": "/404"; "404": "/404";
"500": "/500"; "500": "/500";
"function": "/function";
"function_hide-child": "/function/hide-child";
"function_hide-child_one": "/function/hide-child/one";
"function_hide-child_three": "/function/hide-child/three";
"function_hide-child_two": "/function/hide-child/two";
"function_multi-tab": "/function/multi-tab";
"function_request": "/function/request";
"function_super-page": "/function/super-page";
"function_tab": "/function/tab";
"function_toggle-auth": "/function/toggle-auth";
"iframe-page": "/iframe-page/:url"; "iframe-page": "/iframe-page/:url";
"infra": "/infra"; "infra": "/infra";
"infra_rd-code": "/infra/rd-code"; "infra_rd-code": "/infra/rd-code";
@@ -52,27 +42,6 @@ declare module "@elegant-router/types" {
"personal-center_my-weekly": "/personal-center/my-weekly"; "personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_overtime-application": "/personal-center/overtime-application"; "personal-center_overtime-application": "/personal-center/overtime-application";
"personal-center_pending-approval": "/personal-center/pending-approval"; "personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin";
"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": "/product";
"product_dashboard": "/product/dashboard"; "product_dashboard": "/product/dashboard";
"product_list": "/product/list"; "product_list": "/product/list";
@@ -135,13 +104,11 @@ declare module "@elegant-router/types" {
| "403" | "403"
| "404" | "404"
| "500" | "500"
| "function"
| "iframe-page" | "iframe-page"
| "infra" | "infra"
| "login" | "login"
| "metrics" | "metrics"
| "personal-center" | "personal-center"
| "plugin"
| "product" | "product"
| "project" | "project"
| "system" | "system"
@@ -169,14 +136,6 @@ declare module "@elegant-router/types" {
| "500" | "500"
| "iframe-page" | "iframe-page"
| "login" | "login"
| "function_hide-child_one"
| "function_hide-child_three"
| "function_hide-child_two"
| "function_multi-tab"
| "function_request"
| "function_super-page"
| "function_tab"
| "function_toggle-auth"
| "infra_rd-code" | "infra_rd-code"
| "infra_state-machine" | "infra_state-machine"
| "metrics_member-efficiency" | "metrics_member-efficiency"
@@ -190,23 +149,6 @@ declare module "@elegant-router/types" {
| "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_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_dashboard"
| "product_list" | "product_list"
| "product_requirement" | "product_requirement"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE } from '@/constants/dict'; import { fetchGetOvertimeApplicationStatusDict } from '@/service/api';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue'; import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'OvertimeApplicationSearch' }); defineOptions({ name: 'OvertimeApplicationSearch' });
@@ -21,6 +21,8 @@ const searchModel = reactive<Record<string, any>>({
approverName: '' approverName: ''
}); });
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
let syncingFromSource = false; let syncingFromSource = false;
watch( watch(
@@ -53,6 +55,24 @@ watch(
{ flush: 'sync' } { 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[]>(() => [ const fields = computed<SearchField[]>(() => [
{ {
key: 'applicantName', key: 'applicantName',
@@ -69,8 +89,8 @@ const fields = computed<SearchField[]>(() => [
{ {
key: 'statusCode', key: 'statusCode',
label: '状态', label: '状态',
type: 'dict', type: 'select',
dictCode: RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE, options: statusOptions.value,
placeholder: '请选择状态' placeholder: '请选择状态'
}, },
{ {

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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[];
}

View File

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

View File

@@ -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>

View File

@@ -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'
}
]
}
]
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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'
}
];

View File

@@ -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>

View File

@@ -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'
];

View File

@@ -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>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { BAIDU_MAP_SDK_URL } from '@/constants/map-sdk';
defineOptions({ name: 'BaiduMap' });
window.HOST_TYPE = '2';
const { load } = useScriptTag(BAIDU_MAP_SDK_URL);
const domRef = ref<HTMLDivElement>();
async function renderMap() {
await load(true);
if (!domRef.value) return;
const map = new BMap.Map(domRef.value);
const point = new BMap.Point(114.05834626586915, 22.546789983033168);
map.centerAndZoom(point, 15);
map.enableScrollWheelZoom();
}
onMounted(() => {
renderMap();
});
</script>
<template>
<div ref="domRef" class="h-full w-full"></div>
</template>
<style scoped></style>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { AMAP_SDK_URL } from '@/constants/map-sdk';
defineOptions({ name: 'GaodeMap' });
const { load } = useScriptTag(AMAP_SDK_URL);
const domRef = ref<HTMLDivElement>();
async function renderMap() {
await load(true);
if (!domRef.value) return;
const map = new AMap.Map(domRef.value, {
zoom: 11,
center: [114.05834626586915, 22.546789983033168],
viewMode: '3D'
});
map.getCenter();
}
onMounted(() => {
renderMap();
});
</script>
<template>
<div ref="domRef" class="h-full w-full"></div>
</template>
<style scoped></style>

View File

@@ -1,5 +0,0 @@
import BaiduMap from './baidu-map.vue';
import GaodeMap from './gaode-map.vue';
import TencentMap from './tencent-map.vue';
export { BaiduMap, GaodeMap, TencentMap };

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useScriptTag } from '@vueuse/core';
import { TENCENT_MAP_SDK_URL } from '@/constants/map-sdk';
defineOptions({ name: 'TencentMap' });
const { load } = useScriptTag(TENCENT_MAP_SDK_URL);
const domRef = ref<HTMLDivElement | null>(null);
async function renderMap() {
await load(true);
if (!domRef.value) return;
// eslint-disable-next-line no-new
new TMap.Map(domRef.value, {
center: new TMap.LatLng(39.98412, 116.307484),
zoom: 11,
viewMode: '3D'
});
}
onMounted(() => {
renderMap();
});
</script>
<template>
<div ref="domRef" class="h-full w-full"></div>
</template>
<style scoped></style>

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import { type Component, ref } from 'vue';
import { BaiduMap, GaodeMap, TencentMap } from './components';
defineOptions({ name: 'MapComp' });
interface Map {
id: string;
label: string;
component: Component;
}
const maps: Map[] = [
{ id: 'gaode', label: '高德地图', component: GaodeMap },
{ id: 'tencent', label: '腾讯地图', component: TencentMap },
{ id: 'baidu', label: '百度地图', component: BaiduMap }
];
const activeMap = ref(maps[0].id);
</script>
<template>
<div class="h-full">
<ElCard header="地图插件" class="h-full" content-style="overflow:hidden">
<ElTabs class="h-full">
<ElTabPane
v-for="item in maps"
:key="item.id"
v-model="activeMap"
class="h-full"
:value="item.id"
:label="item.label"
lazy
>
<component :is="item.component" />
</ElTabPane>
</ElTabs>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import VuePdfEmbed from 'vue-pdf-embed';
import { useLoading } from '@sa/hooks';
defineOptions({ name: 'PdfPage' });
const { loading, endLoading } = useLoading(true);
const pdfRef = shallowRef<InstanceType<typeof VuePdfEmbed> | null>(null);
const source = `https://xiaoxian521.github.io/hyperlink/pdf/Cookie%E5%92%8CSession%E5%8C%BA%E5%88%AB%E7%94%A8%E6%B3%95.pdf`;
const showAllPages = ref(false);
const currentPage = ref<number>(1);
const pageCount = ref(1);
function onPdfRendered() {
endLoading();
if (pdfRef.value?.doc) {
pageCount.value = pdfRef.value.doc.numPages;
}
}
function showAllPagesChange() {
currentPage.value = 1;
}
const rotations = [0, 90, 180, 270];
const currentRotation = ref(0);
function handleRotate() {
currentRotation.value = (currentRotation.value + 1) % 4;
}
async function handlePrint() {
await pdfRef.value?.print(undefined, 'test.pdf', true);
}
async function handleDownload() {
await pdfRef.value?.download('test.pdf');
}
</script>
<template>
<div class="overflow-hidden">
<ElCard header="PDF 预览" class="h-full card-wrapper" content-class="overflow-hidden">
<div class="h-[calc(100%-30px)] flex-col-stretch">
<GithubLink link="https://github.com/hrynko/vue-pdf-embed" />
<WebSiteLink label="文档地址:" link="https://www.npmjs.com/package/vue-pdf-embed" />
<div class="flex-y-center justify-end gap-12px">
<ElCheckbox v-model="showAllPages" @change="showAllPagesChange">显示所有页面</ElCheckbox>
<ButtonIcon tooltip-content="旋转90度" @click="handleRotate">
<icon-material-symbols-light:rotate-90-degrees-ccw-outline-rounded />
</ButtonIcon>
<ButtonIcon tooltip-content="打印" @click="handlePrint">
<icon-mdi:printer />
</ButtonIcon>
<ButtonIcon tooltip-content="下载" @click="handleDownload">
<icon-charm:download />
</ButtonIcon>
</div>
<ElScrollbar class="flex-1-hidden">
<NSkeleton v-if="loading" size="small" class="mt-12px" text :repeat="12" />
<VuePdfEmbed
ref="pdfRef"
class="container overflow-auto"
:class="{ 'h-0': loading }"
:rotation="rotations[currentRotation]"
:page="currentPage"
:source="source"
@rendered="onPdfRendered"
/>
</ElScrollbar>
<div class="flex-y-center justify-between">
<div v-if="showAllPages" class="text-18px font-medium">{{ pageCount }}</div>
<ElPagination
v-else
key="pdf-page"
layout="prev, pager, next"
background
:page-count="pageCount"
@current-change="currentPage = $event"
/>
</div>
</div>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { html } from 'pinyin-pro';
import domPurify from 'dompurify';
defineOptions({ name: 'PinyinPage' });
const domRef = ref<HTMLElement | null>(null);
const domRef2 = ref<HTMLElement | null>(null);
const domRef3 = ref<HTMLElement | null>(null);
function renderHtml() {
if (!domRef.value || !domRef2.value || !domRef3.value) return;
const text = 'CN-RDMS 是灿能电力内部使用的研发管理系统前端项目';
const code = domPurify.sanitize(html(text));
const code2 = domPurify.sanitize(html(text, { toneType: 'none' }));
domRef.value.innerHTML = code;
domRef2.value.innerHTML = code2;
domRef3.value.innerHTML = code;
}
onMounted(() => {
renderHtml();
});
</script>
<template>
<div>
<ElCard header="pinyin 插件" class="h-full card-wrapper">
<ElSpace :vertical="true">
<GithubLink link="https://github.com/zh-lx/pinyin-pro" />
<WebSiteLink label="文档地址:" link="https://pinyin-pro.cn/" />
</ElSpace>
<ElDivider content-position="left">常规使用</ElDivider>
<p ref="domRef" class="text-18px"></p>
<ElDivider content-position="left">不带音调</ElDivider>
<p ref="domRef2" class="text-18px"></p>
<ElDivider content-position="left">自定义样式</ElDivider>
<p ref="domRef3" class="custom-style text-18px"></p>
</ElCard>
</div>
</template>
<style lang="scss" scoped>
.custom-style {
:deep(.py-result-item) {
.py-chinese-item {
--uno: text-primary;
}
.py-pinyin-item {
--uno: text-error;
}
}
}
</style>

View File

@@ -1,41 +0,0 @@
<script lang="ts" setup>
import printJS from 'print-js';
defineOptions({ name: 'PrintPage' });
function printTable() {
printJS({
printable: [
{ name: 'CN-RDMS', wechat: 'internal', remark: '内部演示数据' },
{ name: 'CN-RDMS', wechat: 'internal', remark: '内部演示数据' }
],
properties: ['name', 'wechat', 'remark'],
type: 'json'
});
}
function printImage() {
printJS({
printable: [
'https://i.loli.net/2021/11/24/1J6REWXiHomU2kM.jpg',
'https://i.loli.net/2021/11/24/1J6REWXiHomU2kM.jpg'
],
type: 'image',
header: 'Multiple Images',
imageStyle: 'width:100%;'
});
}
</script>
<template>
<div class="h-full">
<ElCard header="打印" class="card-wrapper">
<ElButton type="primary" class="mr-10px" @click="printTable">打印表格</ElButton>
<ElButton type="primary" @click="printImage">打印图片</ElButton>
<template #footer>
<GithubLink label="printJS" link="https://github.com/crabbly/Print.js" class="mt-10px" />
</template>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,60 +0,0 @@
<script setup lang="ts">
import SwiperCore from 'swiper';
import { Navigation, Pagination } from 'swiper/modules';
import { Swiper, SwiperSlide } from 'swiper/vue';
import type { SwiperOptions } from 'swiper/types';
defineOptions({ name: 'SwiperComp' });
type SwiperExampleOptions = Pick<
SwiperOptions,
'navigation' | 'pagination' | 'scrollbar' | 'slidesPerView' | 'slidesPerGroup' | 'spaceBetween' | 'direction' | 'loop'
>;
interface SwiperExample {
id: number;
label: string;
options: Partial<SwiperExampleOptions>;
}
SwiperCore.use([Navigation, Pagination]);
const swiperExample: SwiperExample[] = [
{ id: 0, label: 'Default', options: {} },
{ id: 1, label: 'Navigation', options: { navigation: true } },
{ id: 2, label: 'Pagination', options: { pagination: true } },
{ id: 3, label: 'Pagination dynamic', options: { pagination: { dynamicBullets: true } } },
{ id: 4, label: 'Pagination progress', options: { navigation: true, pagination: { type: 'progressbar' } } },
{ id: 5, label: 'Pagination fraction', options: { navigation: true, pagination: { type: 'fraction' } } },
{ id: 6, label: 'Slides per view', options: { pagination: { clickable: true }, slidesPerView: 3, spaceBetween: 30 } },
{ id: 7, label: 'Infinite loop', options: { navigation: true, pagination: { clickable: true }, loop: true } }
];
</script>
<template>
<div>
<ElCard header="Swiper插件" class="card-wrapper">
<ElSpace :vertical="true">
<GithubLink link="https://github.com/nolimits4web/swiper" />
<WebSiteLink label="vue3版文档地址" link="https://swiperjs.com/vue" />
<WebSiteLink label="插件demo地址" link="https://swiperjs.com/demos" />
</ElSpace>
<ElSpace class="w-full" direction="vertical">
<div v-for="item in swiperExample" :key="item.id" class="w-full">
<h3 class="py-24px text-24px font-bold">{{ item.label }}</h3>
<Swiper v-bind="item.options">
<SwiperSlide v-for="i in 5" :key="i">
<div class="h-240px w-full flex-center border-1px border-#999 text-18px font-bold">Slide{{ i }}</div>
</SwiperSlide>
</Swiper>
</div>
</ElSpace>
</ElCard>
</div>
</template>
<style scoped>
:deep(.el-space__item) {
width: 100%;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,408 +0,0 @@
<script setup lang="tsx">
import { computed, onMounted, ref } from 'vue';
import {
Group,
Image,
ListColumn,
ListTable,
Menu,
PivotChart,
PivotColumnDimension,
PivotCorner,
PivotIndicator,
PivotRowDimension,
PivotTable,
Tag,
Text,
VTable,
registerChartModule
} from '@visactor/vue-vtable';
import VChart from '@visactor/vchart';
import { useThemeStore } from '@/store/modules/theme';
import { customListRecords, listTableRecords, pivotChartColumns, pivotChartIndicators, pivotChartRows } from './data';
registerChartModule('vchart', VChart);
const titleColorPool = ['#3370ff', '#34c724', '#ff9f1a', '#ff4050', '#1f2329'];
const themeStore = useThemeStore();
// list table
const listTableRef = ref(null);
const listOptions = computed(() => {
const options = {
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT
};
return options;
});
const listRecords = ref<Record<string, string | number>[]>(listTableRecords);
// group table
const groupTableRef = ref(null);
const groupOptions = computed(() => {
const options = {
groupBy: ['Category', 'Sub-Category'],
theme: (themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT).extends({
groupTitleStyle: {
fontWeight: 'bold',
bgColor: (args: any) => {
const { col, row, table } = args;
const index = table.getGroupTitleLevel(col, row);
if (index !== undefined) {
return titleColorPool[index % titleColorPool.length] as string;
}
return 'white';
}
}
})
};
return options;
});
const groupRecords = ref<Record<string, string | number>[]>(listTableRecords);
// pivot table
const pivotTableRef = ref(null);
const pivotTableOptions = computed(() => {
return {
tooltip: {
isShowOverflowTextTooltip: true
},
dataConfig: {
sortRules: [
{
sortField: 'Category',
sortBy: ['Office Supplies', 'Technology', 'Furniture']
}
]
},
widthMode: 'standard',
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT,
emptyTip: {
text: 'no data records'
}
};
});
const pivotTableIndicators = ref([
{
indicatorKey: 'Quantity',
title: 'Quantity',
width: 'auto',
showSort: false,
headerStyle: { fontWeight: 'normal' },
style: {
padding: [16, 28, 16, 28],
color(args: any) {
return args.dataValue >= 0 ? 'black' : 'red';
}
}
},
{
indicatorKey: 'Sales',
title: 'Sales',
width: 'auto',
showSort: false,
headerStyle: { fontWeight: 'normal' },
format: (rec: string) => `$${Number(rec).toFixed(2)}`,
style: {
padding: [16, 28, 16, 28],
color(args: any) {
return args.dataValue >= 0 ? 'black' : 'red';
}
}
},
{
indicatorKey: 'Profit',
title: 'Profit',
width: 'auto',
showSort: false,
headerStyle: { fontWeight: 'normal' },
format: (rec: string) => `$${Number(rec).toFixed(2)}`,
style: {
padding: [16, 28, 16, 28],
color(args: any) {
return args.dataValue >= 0 ? 'black' : 'red';
}
}
}
]);
const pivotTableRows = ref([
{
dimensionKey: 'City',
title: 'City',
headerStyle: { textStick: true },
width: 'auto'
}
]);
const pivotTableRecords = ref([]);
// pivot chart
const pivotChartRef = ref(null);
const pivotChartOptions = computed(() => {
return {
rows: pivotChartRows,
columns: pivotChartColumns,
indicators: pivotChartIndicators,
indicatorsAsCol: false,
defaultRowHeight: 200,
defaultHeaderRowHeight: 50,
defaultColWidth: 280,
defaultHeaderColWidth: 100,
indicatorTitle: '指标',
autoWrapText: true,
corner: {
titleOnDimension: 'row',
headerStyle: { autoWrapText: true }
},
legends: {
orient: 'bottom',
type: 'discrete',
data: [
{ label: 'Consumer-Quantity', shape: { fill: '#2E62F1', symbolType: 'circle' } },
{ label: 'Consumer-Quantity', shape: { fill: '#4DC36A', symbolType: 'square' } },
{ label: 'Home Office-Quantity', shape: { fill: '#FF8406', symbolType: 'square' } },
{ label: 'Consumer-Sales', shape: { fill: '#FFCC00', symbolType: 'square' } },
{ label: 'Consumer-Sales', shape: { fill: '#4F44CF', symbolType: 'square' } },
{ label: 'Home Office-Sales', shape: { fill: '#5AC8FA', symbolType: 'square' } },
{ label: 'Consumer-Profit', shape: { fill: '#003A8C', symbolType: 'square' } },
{ label: 'Consumer-Profit', shape: { fill: '#B08AE2', symbolType: 'square' } },
{ label: 'Home Office-Profit', shape: { fill: '#FF6341', symbolType: 'square' } }
]
},
theme: (themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT).extends({
bodyStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 0, 1] },
headerStyle: { borderColor: 'gray', borderLineWidth: [0, 0, 1, 1], hover: { cellBgColor: '#CCE0FF' } },
rowHeaderStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 0], hover: { cellBgColor: '#CCE0FF' } },
cornerHeaderStyle: { borderColor: 'gray', borderLineWidth: [0, 1, 1, 0], hover: { cellBgColor: '' } },
cornerRightTopCellStyle: { borderColor: 'gray', borderLineWidth: [0, 0, 1, 1], hover: { cellBgColor: '' } },
cornerLeftBottomCellStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 0], hover: { cellBgColor: '' } },
cornerRightBottomCellStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 0, 1], hover: { cellBgColor: '' } },
rightFrozenStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 1, 1], hover: { cellBgColor: '' } },
bottomFrozenStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 1], hover: { cellBgColor: '' } },
selectionStyle: { cellBgColor: '', cellBorderColor: '' },
frameStyle: { borderLineWidth: 0 }
}),
emptyTip: {
text: 'no data records'
}
};
});
const pivotChartRecords = ref([] as any);
const handleLegendItemClick = (args: { value: any }) => {
(pivotChartRef?.value as any)?.vTableInstance.updateFilterRules([
{
filterKey: 'Segment-Indicator',
filteredValues: args.value
}
]);
};
// custom layout list table
const customLayoutListTableRef = ref(null);
const customLayoutListTableOptions = computed(() => {
return {
defaultRowHeight: 80,
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT
};
});
const customLayoutListTableRecords = ref(customListRecords);
const customLayoutListTableColumnStyle = ref({ fontFamily: 'Arial', fontSize: 12, fontWeight: 'bold' });
onMounted(() => {
// pivot tablt records
fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_Pivot_data.json')
.then(res => res.json())
.then(jsonData => {
// update record
pivotTableRecords.value = jsonData;
});
// pivot chart records
fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_Pivot_Chart_data.json')
.then(res => res.json())
.then(data => {
// update record
pivotChartRecords.value = data;
});
});
</script>
<template>
<div class="h-full">
<ElSpace fill direction="vertical" class="mb-16px w-full" :size="16">
<ElCard header="List Table" class="h-full w-2/3 card-wrapper">
<ListTable ref="listTableRef" :options="listOptions" :records="listRecords" height="400px">
<ListColumn field="Order ID" title="Order ID" width="auto" />
<ListColumn field="Customer ID" title="Customer ID" width="auto" />
<ListColumn field="Product Name" title="Product Name" width="auto" />
<ListColumn field="Category" title="Category" width="auto" />
<ListColumn field="Sub-Category" title="Sub-Category" width="auto" />
<ListColumn field="Region" title="Region" width="auto" />
<ListColumn field="City" title="City" width="auto" />
<ListColumn field="Order Date" title="Order Date" width="auto" />
<ListColumn field="Quantity" title="Quantity" width="auto" />
<ListColumn field="Sales" title="Sales" width="auto" />
<ListColumn field="Profit" title="Profit" width="auto" />
</ListTable>
</ElCard>
<ElCard header="Group Table" class="h-full w-2/3 card-wrapper">
<ListTable ref="groupTableRef" :options="groupOptions" :records="groupRecords" height="400px">
<ListColumn field="Order ID" title="Order ID" width="auto" />
<ListColumn field="Customer ID" title="Customer ID" width="auto" />
<ListColumn field="Product Name" title="Product Name" width="auto" />
<ListColumn field="Category" title="Category" width="auto" />
<ListColumn field="Sub-Category" title="Sub-Category" width="auto" />
<ListColumn field="Region" title="Region" width="auto" />
<ListColumn field="City" title="City" width="auto" />
<ListColumn field="Order Date" title="Order Date" width="auto" />
<ListColumn field="Quantity" title="Quantity" width="auto" />
<ListColumn field="Sales" title="Sales" width="auto" />
<ListColumn field="Profit" title="Profit" width="auto" />
</ListTable>
</ElCard>
<ElCard header="Pivot Table" class="h-full w-2/3 card-wrapper">
<PivotTable ref="pivotTableRef" :options="pivotTableOptions" :records="pivotTableRecords" height="400px">
<PivotColumnDimension
title="Category"
dimension-key="Category"
:header-style="{ textStick: true }"
width="auto"
/>
<PivotRowDimension
v-for="(row, index) in pivotTableRows"
:key="index"
:dimension-key="row.dimensionKey"
:title="row.title"
:header-style="row.headerStyle"
:width="row.width"
/>
<PivotIndicator
v-for="(indicator, index) in pivotTableIndicators"
:key="index"
:indicator-key="indicator.indicatorKey"
:title="indicator.title"
:width="indicator.width"
:show-sort="indicator.showSort"
:header-style="indicator.headerStyle"
:format="indicator.format"
:style="indicator.style"
/>
<PivotCorner title-on-dimension="row" />
<Menu menu-type="html" :context-menu-items="['copy', 'paste', 'delete', '...']" />
</PivotTable>
</ElCard>
<ElCard header="Pivot Chart" class="h-full w-2/3 card-wrapper">
<PivotChart
ref="pivotChartRef"
:options="pivotChartOptions"
:records="pivotChartRecords"
height="800px"
@on-legend-item-click="handleLegendItemClick"
/>
</ElCard>
<ElCard header="Custom Component" class="h-full w-2/3 card-wrapper">
<ListTable
ref="customLayoutListTableRef"
:options="customLayoutListTableOptions"
:records="customLayoutListTableRecords"
height="400px"
>
<!-- Order Number Column -->
<ListColumn field="bloggerId" title="Order Number" width="100" />
<!-- Anchor Nickname Column with Custom Layout -->
<ListColumn field="bloggerName" title="Anchor Nickname" :width="330">
<template #customLayout="{ record, height, width }">
<Group :height="height" :width="width" display="flex" flex-direction="row" flex-wrap="nowrap">
<!-- Avatar Group -->
<Group
:height="height"
:width="60"
display="flex"
flex-direction="column"
align-items="center"
justify-content="space-around"
fill="red"
:opacity="0.1"
>
<Image id="icon0" :width="50" :height="50" :image="record.bloggerAvatar" :corner-radius="25" />
</Group>
<!-- Blogger Info Group -->
<Group :height="height" :width="width - 60" display="flex" flex-direction="column" flex-wrap="nowrap">
<Group
:height="height / 2"
:width="width - 60"
display="flex"
flex-wrap="wrap"
align-items="center"
fill="orange"
:opacity="0.1"
>
<Text
:text="record.bloggerName"
:font-size="13"
font-family="sans-serif"
fill="black"
:bounds-padding="[0, 0, 0, 10]"
/>
<Image
id="location"
image="https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/location.svg"
:width="15"
:height="15"
:bounds-padding="[0, 0, 0, 10]"
cursor="pointer"
/>
<Text :text="record.city" :font-size="11" font-family="sans-serif" fill="#6f7070" />
</Group>
<!-- Tags Group -->
<Group
:height="height / 2"
:width="width - 60"
display="flex"
align-items="center"
fill="yellow"
:opacity="0.1"
>
<Tag
v-for="tag in record?.tags"
:key="tag"
:text="tag"
:text-style="{ fontSize: 10, fontFamily: 'sans-serif', fill: 'rgb(51, 101, 238)' }"
:panel="{ visible: true, fill: '#f4f4f2', cornerRadius: 5 }"
:space="5"
:bounds-padding="[0, 0, 0, 5]"
/>
</Group>
</Group>
</Group>
</template>
</ListColumn>
<!-- Other Columns -->
<ListColumn
field="fansCount"
title="Fans Count"
width="120"
:field-format="rec => rec.fansCount + 'w'"
:style="customLayoutListTableColumnStyle"
/>
<ListColumn field="worksCount" title="Works Count" :style="customLayoutListTableColumnStyle" width="135" />
<ListColumn
field="viewCount"
title="View Count"
width="120"
:field-format="rec => rec.viewCount + 'w'"
:style="customLayoutListTableColumnStyle"
/>
</ListTable>
</ElCard>
<ElCard class="h-full w-2/3 card-wrapper">
<WebSiteLink label="More VTable Demos: " link="https://www.visactor.com/vtable/example" />
</ElCard>
</ElSpace>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script lang="ts" setup>
import { onMounted, shallowRef } from 'vue';
import TypeIt from 'typeit';
import type { Options } from 'typeit';
import type { El } from 'typeit/dist/types';
defineOptions({ name: 'TypeIt' });
const textRef = shallowRef<El>();
function init() {
if (!textRef.value) return;
const options: Options = {
strings: 'CN-RDMS 是灿能电力内部使用的研发管理系统前端项目',
lifeLike: true,
speed: 120,
loop: true
};
const initTypeIt = new TypeIt(textRef.value, options);
initTypeIt.go();
}
onMounted(() => {
init();
});
</script>
<template>
<div>
<ElCard header="打字机 插件" class="h-full card-wrapper">
<ElSpace direction="vertical">
<GithubLink link="https://github.com/alexmacarthur/typeit" />
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
</ElSpace>
<ElDivider content-position="left">基本示例</ElDivider>
<span ref="textRef" class="text-18px"></span>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import Player from 'xgplayer';
import 'xgplayer/dist/index.min.css';
defineOptions({ name: 'VideoComp' });
const domRef = ref<HTMLElement>();
const player = ref<Player>();
function renderXgPlayer() {
if (!domRef.value) return;
const url = 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4';
player.value = new Player({
el: domRef.value,
url,
playbackRate: [0.5, 0.75, 1, 1.5, 2]
});
}
function destroyXgPlayer() {
player.value?.destroy();
}
onMounted(() => {
renderXgPlayer();
});
onUnmounted(() => {
destroyXgPlayer();
});
</script>
<template>
<div>
<ElCard header="视频播放器插件" class="h-full card-wrapper">
<div class="flex-center">
<div ref="domRef" class="h-auto w-full shadow-md"></div>
</div>
</ElCard>
</div>
</template>
<style scoped></style>

View File

@@ -1,767 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProduct } from '../shared/use-current-product';
import ProductActivityTimelinePanel from './modules/product-activity-timeline-panel.vue';
import {
buildProductHomepageBanner,
buildRequirementPoolRecentChanges,
buildRequirementPoolSummary,
getProductHomepageExtensionModules
} from './homepage';
import { productHomepageExtensionMock, productRequirementPoolMock } from './mock';
defineOptions({ name: 'ProductDashboard' });
const { currentObjectId } = useCurrentProduct();
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const pageLoading = ref(false);
const productDetail = ref<Api.Product.Product | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const latestActivityTime = ref('');
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(productRequirementPoolMock.summary));
const requirementPoolRecentChanges = computed(() =>
buildRequirementPoolRecentChanges(productRequirementPoolMock.recentChanges)
);
const homepageBanner = computed(() =>
buildProductHomepageBanner({
product: productDetail.value,
settings: settings.value,
members: members.value,
requirementSummary: requirementPoolSummary.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProductHomepageExtensionModules(productHomepageExtensionMock));
const directionLabel = computed(() => getDirectionDictLabel(homepageBanner.value.identity.directionCode, '--'));
const bannerFacts = computed(() => {
const [managerFact, roleFact] = homepageBanner.value.identity.facts;
return [
{
label: '产品方向',
value: directionLabel.value,
fullWidth: false
},
{
label: managerFact?.label || '产品经理',
value: managerFact?.value || '--',
fullWidth: false
},
{
label: roleFact?.label || '角色摘要',
value: roleFact?.value || '--',
fullWidth: true
}
];
});
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `product-homepage-banner--${statusCode}` : 'product-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `product-homepage-banner__status-word--${statusCode}`
: 'product-homepage-banner__status-word--default';
});
function handleLatestActivityTimeChange(value: string) {
latestActivityTime.value = value;
}
async function loadDashboardData(objectId: string) {
pageLoading.value = true;
try {
const [productResult, settingsResult, membersResult] = await Promise.all([
fetchGetProduct(objectId),
fetchGetProductSettings(objectId),
fetchGetProductMembers(objectId)
]);
productDetail.value = productResult.error ? null : productResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
productDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadDashboardData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-homepage">
<section class="product-homepage-banner" :class="bannerStatusClass">
<div class="product-homepage-banner__identity">
<div class="product-homepage-banner__title-group">
<div class="product-homepage-banner__title-main min-w-0">
<div class="product-homepage-banner__title-row">
<h1 class="product-homepage-banner__title">{{ homepageBanner.identity.name }}</h1>
<span class="product-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="product-homepage-banner__subtitle">
<span class="product-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="product-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="product-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="product-homepage-banner__fact"
:class="{ 'product-homepage-banner__fact--full': item.fullWidth }"
>
<span class="product-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="product-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="product-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="product-homepage-banner__metric">
<span class="product-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="product-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="product-homepage-main">
<ProductActivityTimelinePanel
:product-id="currentObjectId || ''"
@latest-time-change="handleLatestActivityTimeChange"
/>
<div class="product-homepage-main__aside">
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池管理概览</h3>
<p class="product-homepage-panel__desc">先看需求池现在的总体规模状态结构和待处理压力</p>
</div>
</template>
<div class="product-homepage-requirement-summary">
<div class="product-homepage-requirement-summary__metrics">
<article
v-for="item in requirementPoolSummary.metrics"
:key="item.label"
class="product-homepage-requirement-summary__metric"
>
<span class="product-homepage-requirement-summary__metric-label">{{ item.label }}</span>
<strong class="product-homepage-requirement-summary__metric-value">{{ item.value }}</strong>
</article>
</div>
<div class="product-homepage-requirement-summary__distribution">
<div
v-for="item in requirementPoolSummary.distribution"
:key="item.label"
class="product-homepage-requirement-summary__distribution-item"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池最近变化</h3>
<p class="product-homepage-panel__desc">承接需求新增状态流转和关闭情况和产品动态时间线分开表达</p>
</div>
</template>
<div v-if="requirementPoolRecentChanges.length" class="product-homepage-requirement-changes">
<article
v-for="item in requirementPoolRecentChanges"
:key="item.id"
class="product-homepage-requirement-changes__item"
>
<div class="product-homepage-requirement-changes__meta">
<ElTag type="info" effect="plain" size="small">{{ item.actionLabel }}</ElTag>
<span class="product-homepage-requirement-changes__time">{{ item.time }}</span>
</div>
<strong class="product-homepage-requirement-changes__title">{{ item.title }}</strong>
<p class="product-homepage-requirement-changes__status">当前状态{{ item.statusLabel }}</p>
</article>
</div>
<ElEmpty v-else description="当前暂无需求池最近变化" :image-size="72" />
</ElCard>
</div>
</section>
<section class="product-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">{{ module.title }}</h3>
<p class="product-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="product-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="product-homepage-extension__item">
<span class="product-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.product-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.product-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.product-homepage-banner--active {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--abandoned {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.product-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.product-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.product-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.product-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: -0.03em;
}
.product-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.product-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.product-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.product-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.product-homepage-banner__status-word--active {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
}
.product-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
}
.product-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
}
.product-homepage-banner__status-word--abandoned {
color: transparent;
background: linear-gradient(180deg, rgb(225 29 72 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(225 29 72 / 16%);
}
.product-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.product-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.product-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.product-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.product-homepage-banner__fact--full .product-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.product-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
}
.product-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: break-word;
}
.product-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-panel {
overflow: hidden;
}
.product-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.product-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-homepage-timeline__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 6px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.product-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.product-homepage-timeline__item:last-child .product-homepage-timeline__line {
opacity: 0;
}
.product-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.product-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.product-homepage-requirement-summary {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-requirement-summary__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-requirement-summary__metric {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.product-homepage-requirement-summary__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-requirement-summary__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 24px;
line-height: 1.1;
}
.product-homepage-requirement-summary__distribution {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-requirement-summary__distribution-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.product-homepage-requirement-summary__distribution-item strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.product-homepage-requirement-changes {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-homepage-requirement-changes__item {
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-requirement-changes__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.product-homepage-requirement-changes__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-requirement-changes__title {
display: block;
margin-top: 10px;
color: rgb(15 23 42 / 98%);
font-size: 15px;
line-height: 1.65;
}
.product-homepage-requirement-changes__status {
margin: 8px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.product-homepage-extension__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
flex-shrink: 0;
}
@media (width <= 1280px) {
.product-homepage-banner,
.product-homepage-main,
.product-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.product-homepage-banner {
padding: 18px;
}
.product-homepage-banner__title-row {
flex-wrap: wrap;
}
.product-homepage-banner__title {
font-size: 28px;
}
.product-homepage-banner__status-word {
font-size: 22px;
}
.product-homepage-banner__facts,
.product-homepage-banner__metrics,
.product-homepage-requirement-summary__metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -10,6 +10,7 @@ import {
RDMS_REQ_PRIORITY_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict'; } from '@/constants/dict';
import { getStatusTagType } from '@/constants/status-tag';
import { import {
fetchChangeRequirementStatus, fetchChangeRequirementStatus,
fetchDeleteRequirement, fetchDeleteRequirement,
@@ -31,7 +32,6 @@ import {
ACTION_TYPE_MAP, ACTION_TYPE_MAP,
type RequirementStatusActionCode, type RequirementStatusActionCode,
getRequirementActionDisplayName, getRequirementActionDisplayName,
getRequirementStatusTagType,
isRequirementActionNeedProject, isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice, isRequirementActionNeedReviewChoice,
isRequirementActionTerminal isRequirementActionTerminal
@@ -375,7 +375,7 @@ const columns = computed(() => [
width: 100, width: 100,
align: 'center', align: 'center',
formatter: (row: Api.Product.Requirement) => ( formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag> <ElTag type={getStatusTagType('productRequirement', row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
) )
}, },
{ {

View File

@@ -90,22 +90,7 @@ export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> =
close: 'danger', close: 'danger',
delete: 'danger' delete: 'danger'
}; };
export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) { export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) {
const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close']; const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close'];
return terminalActions.includes(actionCode); return terminalActions.includes(actionCode);

View File

@@ -1,6 +1,4 @@
import { type Ref, computed, markRaw } from 'vue'; import { markRaw } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { canReportTaskWorklog } from '../shared';
import { useTaskPermissions } from './use-task-permissions'; import { useTaskPermissions } from './use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline'; import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline'; import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
@@ -58,27 +56,21 @@ const STATUS_ACTION_ORDER: Record<string, number> = {
* *
* 表格操作列与看板卡片操作区共用同一份语义:填报 / 编辑 / 删除 / 状态推进按钮, * 表格操作列与看板卡片操作区共用同一份语义:填报 / 编辑 / 删除 / 状态推进按钮,
* 含 auto_start 过滤、complete 进度 100% 兜底、按钮排序与 icon/type 映射。 * 含 auto_start 过滤、complete 进度 100% 兜底、按钮排序与 icon/type 映射。
*
* dataRef 用于填报按钮的"叶子"判定canReportTaskWorklog 需要全量行集合)。
*/ */
export function useTaskActions(dataRef: Ref<Api.Project.ProjectTask[]>, emits: TaskActionEmits) { export function useTaskActions(emits: TaskActionEmits) {
const authStore = useAuthStore(); const { canEditTask, canDeleteTask } = useTaskPermissions();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
function createActions(row: Api.Project.ProjectTask): TaskAction[] { function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = []; const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定 // 工作日志:行操作入口始终显示——查看人人可看;新增/编辑由弹层内 canSubmit 按身份状态控制
if (hasReportWorklogPermission() && canReportTaskWorklog(row, dataRef.value, currentUserId.value)) { actions.push({
actions.push({ key: 'report',
key: 'report', tooltip: '工作日志',
tooltip: '填报', icon: markRaw(IconMdiClipboardEditOutline),
icon: markRaw(IconMdiClipboardEditOutline), type: 'primary',
type: 'primary', onClick: () => emits.report(row)
onClick: () => emits.report(row) });
});
}
if (canEditTask(row)) { if (canEditTask(row)) {
actions.push({ actions.push({

View File

@@ -118,10 +118,6 @@ export function useTaskPermissions() {
return isTopLevelTask(task) && currentUserId.value === task.executionOwnerId; return isTopLevelTask(task) && currentUserId.value === task.executionOwnerId;
} }
function canReportTaskWorklog(): boolean {
return hasPermission('project:task:worklog');
}
return { return {
// execution // execution
canEditExecution, canEditExecution,
@@ -134,7 +130,6 @@ export function useTaskPermissions() {
canDeleteTask, canDeleteTask,
canCreateTopLevelTask, canCreateTopLevelTask,
canCreateSubTask, canCreateSubTask,
canManageTaskAssignee, canManageTaskAssignee
canReportTaskWorklog
}; };
} }

View File

@@ -684,10 +684,11 @@ async function confirmDeleteExecution(payload: { name: string; confirmText: stri
confirmText: payload.confirmText, confirmText: payload.confirmText,
reason: payload.reason reason: payload.reason
}); });
if (error) return; // 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
window.$message?.success('删除成功'); // 两种情况都关弹层 + 刷新:失败也要让用户离开已失效的弹层、看到最新数据。
deleteDialogVisible.value = false; deleteDialogVisible.value = false;
selectedExecution.value = null; selectedExecution.value = null;
if (!error) window.$message?.success('删除成功');
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷 // 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([ await Promise.all([
reloadExecutionData(1), reloadExecutionData(1),

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
import { VueDraggable } from 'vue-draggable-plus'; import { VueDraggable } from 'vue-draggable-plus';
import { Calendar, Flag, Loading, Lock, User } from '@element-plus/icons-vue'; import { Calendar, Flag, Loading, Lock, User } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
@@ -201,10 +201,7 @@ watch(
); );
// 看板卡片操作按钮(与表格操作列同语义)。 // 看板卡片操作按钮(与表格操作列同语义)。
// 兼容 useTaskActions 的"叶子判定"需求:拍平当前已加载的全部任务做集合。 const { createActions } = useTaskActions({
const allLoadedTasks = computed(() => columns.value.flatMap(item => item.tasks));
const { createActions } = useTaskActions(allLoadedTasks, {
edit: row => emit('edit', row), edit: row => emit('edit', row),
report: row => emit('report', row), report: row => emit('report', row),
remove: row => emit('delete', row), remove: row => emit('delete', row),
@@ -364,7 +361,13 @@ onBeforeUnmount(() => {
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" /> <ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
</div> </div>
<div v-if="createActions(task).length" class="task-board-card-item__actions" @click.stop> <div
v-if="createActions(task).length"
class="task-board-card-item__actions"
@click.stop
@pointerdown.stop
@mousedown.stop
>
<ElTooltip v-for="action in createActions(task)" :key="action.key" :content="action.tooltip"> <ElTooltip v-for="action in createActions(task)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()"> <ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-15px" /> <component :is="action.icon" class="text-15px" />

View File

@@ -6,6 +6,7 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue'; import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue'; import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' }); defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' });
@@ -53,7 +54,7 @@ const parentTaskOptions = computed(() => {
<ElFormItem label="任务类型"> <ElFormItem label="任务类型">
<DictSelect :model-value="taskType" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" disabled placeholder="--" /> <DictSelect :model-value="taskType" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" disabled placeholder="--" />
</ElFormItem> </ElFormItem>
<ElFormItem label="父任务"> <ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无"> <ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无">
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" /> <ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect> </ElSelect>

View File

@@ -10,6 +10,8 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue'; import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue'; import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' }); defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
type OperateMode = 'create' | 'edit'; type OperateMode = 'create' | 'edit';
@@ -342,7 +344,7 @@ defineExpose({
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="父任务"> <ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务"> <ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务">
<ElOption <ElOption
v-for="item in selectableParentTasks" v-for="item in selectableParentTasks"

View File

@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRef } from 'vue'; import { computed } from 'vue';
import type { PaginationProps } from 'element-plus'; import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import { formatDate, formatDateRange, getTaskStatusName, getTaskStatusTagType } from '../shared'; import {
SHOW_TASK_PARENT_FIELD,
formatDate,
formatDateRange,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskActions } from '../composables/use-task-actions'; import { useTaskActions } from '../composables/use-task-actions';
defineOptions({ name: 'ProjectExecutionTaskTableView' }); defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -50,7 +56,7 @@ function getRoleLabel(row: Api.Project.ProjectTask): { label: string; type: Role
return { label: '旁观', type: undefined }; return { label: '旁观', type: undefined };
} }
const { createActions } = useTaskActions(toRef(props, 'data'), { const { createActions } = useTaskActions({
edit: row => emit('edit', row), edit: row => emit('edit', row),
report: row => emit('report', row), report: row => emit('report', row),
remove: row => emit('delete', row), remove: row => emit('delete', row),
@@ -141,7 +147,12 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip> <ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template> <template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn v-if="!crossExecutionMode" label="父任务" min-width="140" show-overflow-tooltip> <ElTableColumn
v-if="!crossExecutionMode && SHOW_TASK_PARENT_FIELD"
label="父任务"
min-width="140"
show-overflow-tooltip
>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template> <template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="进度" width="160"> <ElTableColumn label="进度" width="160">

View File

@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project'; import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared'; import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared'; import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue'; import TaskWorklogPanel from './task-worklog-panel.vue';
@@ -25,7 +24,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const authStore = useAuthStore(); const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const currentUserId = computed(() => authStore.userInfo.userId || ''); const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value)); const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const isActiveAssignee = computed(() => const isActiveAssignee = computed(() =>
@@ -33,13 +31,11 @@ const isActiveAssignee = computed(() =>
); );
// 工时面板顶部「填报」按钮的可见度与任务行操作列的「填报」按钮同源§4.8.4 矩阵 + 业务事实修正): // 工时面板顶部「填报」按钮的可见度与任务行操作列的「填报」按钮同源§4.8.4 矩阵 + 业务事实修正):
// - 权限码 project:task:worklog // - 身份:任务负责人 OR 活跃协办人(非任务团队成员不显示填报,不再卡 project:task:worklog 权限码)
// - 身份:任务负责人 OR 活跃协办人
// - 状态pending首次填触发 auto_startOR active OR completedcompleted 后填报不回写进度,由 form-dialog 内进度只读兜底) // - 状态pending首次填触发 auto_startOR active OR completedcompleted 后填报不回写进度,由 form-dialog 内进度只读兜底)
// 不做叶子判定——详情入口已锁定单条任务,无父子歧义 // 不做叶子判定——详情入口已锁定单条任务,无父子歧义
const canSubmitWorklog = computed(() => { const canSubmitWorklog = computed(() => {
if (!props.task || !currentUserId.value) return false; if (!props.task || !currentUserId.value) return false;
if (!objectContextStore.buttonCodes.includes('project:task:worklog')) return false;
if (!isOwner.value && !isActiveAssignee.value) return false; if (!isOwner.value && !isActiveAssignee.value) return false;
return ( return (
props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed' props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed'
@@ -60,19 +56,17 @@ const plannedEndText = computed(() => (props.task?.plannedEndDate ? formatDate(p
const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--')); const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--'));
const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--')); const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--'));
// 协办人视角 records 只含自身;责任人视角 records 全员 // 工作日志查看全部开放:不分身份,records 一律含该任务全员
const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0)); const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0));
const totalHoursText = computed(() => { const totalHoursText = computed(() => {
if (recordsLoading.value) return '...'; if (recordsLoading.value) return '...';
return `${totalHours.value.toFixed(1)} h`; return `${totalHours.value.toFixed(1)} h`;
}); });
// 责任人视角下"总工时" hover 展示按用户分组的明细;协办人视角不计算 // "总工时" hover 展示按用户分组的明细(查看全部开放,所有人都看得到)。
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人); // 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
// 没填过工时的显示 0h // 没填过工时的显示 0h
const hoursByUserDetail = computed(() => { const hoursByUserDetail = computed(() => {
if (!isOwner.value) return [];
const sumMap = new Map<string, number>(); const sumMap = new Map<string, number>();
for (const item of records.value) { for (const item of records.value) {
sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0)); sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0));
@@ -122,14 +116,11 @@ async function loadRecords() {
} }
recordsLoading.value = true; recordsLoading.value = true;
// 查看全部开放:不按身份裁剪,所有人一律拉该任务全员工时
const params: Api.Project.TaskWorklogSearchParams = { const params: Api.Project.TaskWorklogSearchParams = {
pageNo: 1, pageNo: 1,
pageSize: -1 pageSize: -1
}; };
// 协办人视角:只看自己的 worklogowner 视角:全量加载
if (!isOwner.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage( const { error, data } = await fetchGetProjectTaskWorklogPage(
props.task.projectId, props.task.projectId,
@@ -186,7 +177,7 @@ watch(
<div class="task-worklog-content__card"> <div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">总工时</span> <span class="task-worklog-content__card-label">总工时</span>
<ElTooltip <ElTooltip
v-if="isOwner && hoursByUserDetail.length > 0" v-if="hoursByUserDetail.length > 0"
placement="top" placement="top"
effect="light" effect="light"
popper-class="task-worklog-content__hours-popper" popper-class="task-worklog-content__hours-popper"
@@ -237,7 +228,7 @@ watch(
:task-progress-rate="task.progressRate" :task-progress-rate="task.progressRate"
:can-submit="canSubmitWorklog" :can-submit="canSubmitWorklog"
:external-list="records" :external-list="records"
:show-assignee-column="isOwner" :show-assignee-column="true"
@changed="handleWorklogChanged" @changed="handleWorklogChanged"
/> />
</div> </div>

View File

@@ -473,7 +473,7 @@ watch(
<template> <template>
<div class="task-worklog-panel"> <div class="task-worklog-panel">
<header v-if="canCreate" class="task-worklog-panel__header"> <header v-if="canCreate" class="task-worklog-panel__header">
<ElButton type="primary" :icon="Plus" size="small" @click="handleCreate">填报</ElButton> <ElButton type="primary" :icon="Plus" size="small" @click="handleCreate">工作日志</ElButton>
</header> </header>
<ElTable <ElTable
@@ -484,7 +484,7 @@ watch(
empty-text="暂无工作日志" empty-text="暂无工作日志"
class="task-worklog-panel__table" class="task-worklog-panel__table"
> >
<ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" /> <ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" fixed="left" />
<ElTableColumn label="粒度" width="70" align="center"> <ElTableColumn label="粒度" width="70" align="center">
<template #default="{ row }"> <template #default="{ row }">
<ElTag <ElTag

View File

@@ -696,10 +696,11 @@ async function confirmDeleteTask(payload: { name: string; confirmText: string; r
confirmText: payload.confirmText, confirmText: payload.confirmText,
reason: payload.reason reason: payload.reason
}); });
if (error) return; // 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
window.$message?.success('删除成功'); // 两种情况都关弹层 + 刷新列表:失败也要让用户离开已失效的弹层、看到最新数据。
deleteTaskDialogVisible.value = false; deleteTaskDialogVisible.value = false;
deleteTaskTarget.value = null; deleteTaskTarget.value = null;
if (!error) window.$message?.success('删除成功');
await Promise.all([refreshTableData(), loadTaskStatusBoard()]); await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
} }

View File

@@ -5,6 +5,13 @@ type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode; type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType; type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType;
/**
* 是否在任务界面展示「父任务」相关露出(表格列 / 新建编辑下拉 / 详情只读字段)。
* 当前业务经执行分层后极少有子任务需求,暂统一隐藏,使任务呈扁平的一级任务列表;
* 底层父子数据与级联完成逻辑保留不动,将来恢复子任务功能改回 true 即可。
*/
export const SHOW_TASK_PARENT_FIELD = false;
export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = { export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = {
join: '加入', join: '加入',
inactive: '失效', inactive: '失效',

View File

@@ -1,785 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProject, fetchGetProjectMembers, fetchGetProjectSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProject } from '../../shared/use-current-project';
import {
buildProjectHomepageBanner,
buildProjectHomepageTimeline,
buildProjectScheduleOverview,
buildProjectTeamOverview,
getProjectHomepageExtensionModules
} from './homepage';
import { projectHomepageExtensionMock } from './mock';
defineOptions({ name: 'ProjectOverview' });
const { currentObjectId, currentProject } = useCurrentProject();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const pageLoading = ref(false);
const projectDetail = ref<Api.Project.Project | null>(null);
const settings = ref<Api.Project.ProjectSettings | null>(null);
const members = ref<Api.Project.ProjectMember[]>([]);
const latestActivityTime = ref('');
const timelineItems = computed(() => buildProjectHomepageTimeline(projectDetail.value, settings.value, members.value));
const scheduleOverview = computed(() => buildProjectScheduleOverview(projectDetail.value));
const teamOverview = computed(() => buildProjectTeamOverview(members.value));
const homepageBanner = computed(() =>
buildProjectHomepageBanner({
project: projectDetail.value,
settings: settings.value,
members: members.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProjectHomepageExtensionModules(projectHomepageExtensionMock));
const directionLabel = computed(() => getDirectionLabel(homepageBanner.value.identity.directionCode, '--'));
const projectTypeLabel = computed(() => getProjectTypeLabel(homepageBanner.value.identity.projectType, '--'));
const bannerFacts = computed(() => [
{
label: '项目方向',
value: directionLabel.value,
fullWidth: false
},
{
label: '项目类型',
value: projectTypeLabel.value,
fullWidth: false
},
...homepageBanner.value.identity.facts
]);
const progressValue = computed(() => projectDetail.value?.progressRate ?? 0);
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `project-homepage-banner--${statusCode}` : 'project-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `project-homepage-banner__status-word--${statusCode}`
: 'project-homepage-banner__status-word--default';
});
async function loadOverviewData(objectId: string) {
pageLoading.value = true;
try {
const [projectResult, settingsResult, membersResult] = await Promise.all([
fetchGetProject(objectId),
fetchGetProjectSettings(objectId),
fetchGetProjectMembers(objectId)
]);
projectDetail.value = projectResult.error ? null : projectResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
latestActivityTime.value = timelineItems.value[0]?.time || '';
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
projectDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadOverviewData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="project-homepage">
<section class="project-homepage-banner" :class="bannerStatusClass">
<div class="project-homepage-banner__identity">
<div class="project-homepage-banner__title-group">
<div class="project-homepage-banner__title-main min-w-0">
<div class="project-homepage-banner__title-row">
<h1 class="project-homepage-banner__title">
{{ homepageBanner.identity.name || currentProject?.projectName || '--' }}
</h1>
<span class="project-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="project-homepage-banner__subtitle">
<span class="project-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="project-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="project-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="project-homepage-banner__fact"
:class="{ 'project-homepage-banner__fact--full': item.fullWidth }"
>
<span class="project-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="project-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="project-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="project-homepage-banner__metric">
<span class="project-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="project-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="project-homepage-main">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目动态时间线</h3>
<p class="project-homepage-panel__desc">
先展示项目创建状态动作实际日期和团队变化后续可替换为专用动态接口
</p>
</div>
</template>
<div v-if="timelineItems.length" class="project-homepage-timeline">
<article v-for="item in timelineItems" :key="item.key" class="project-homepage-timeline__item">
<div class="project-homepage-timeline__rail">
<span class="project-homepage-timeline__dot" :class="`project-homepage-timeline__dot--${item.tone}`" />
<span class="project-homepage-timeline__line" />
</div>
<div class="project-homepage-timeline__content">
<div class="project-homepage-timeline__meta">
<ElTag effect="plain" size="small">{{ item.tag }}</ElTag>
<span class="project-homepage-timeline__time">{{ item.time }}</span>
</div>
<p class="project-homepage-timeline__sentence">
<strong class="project-homepage-timeline__headline">{{ item.title }}</strong>
<span>{{ item.content }}</span>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="当前暂无可展示的项目动态" :image-size="88" />
</ElCard>
<div class="project-homepage-main__aside">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">计划进展概览</h3>
<p class="project-homepage-panel__desc">先看当前进度计划周期和实际执行日期是否已经闭环</p>
</div>
</template>
<div class="project-homepage-schedule">
<div class="project-homepage-schedule__progress">
<strong>{{ progressValue }}%</strong>
<ElProgress
:percentage="progressValue"
:stroke-width="8"
:show-text="false"
:color="progressValue >= 100 ? '#10b981' : progressValue >= 50 ? '#3b82f6' : '#6366f1'"
/>
</div>
<div class="project-homepage-summary-metrics">
<article
v-for="item in scheduleOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div class="project-homepage-schedule__dates">
<div v-for="item in scheduleOverview.dates" :key="item.label" class="project-homepage-schedule__date">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目团队概览</h3>
<p class="project-homepage-panel__desc">承接当前成员规模负责人和角色结构和设置页团队维护分开表达</p>
</div>
</template>
<div class="project-homepage-team">
<div class="project-homepage-summary-metrics">
<article
v-for="item in teamOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div v-if="teamOverview.roles.length" class="project-homepage-team__roles">
<div v-for="item in teamOverview.roles" :key="item.label" class="project-homepage-team__role">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="72" />
</div>
</ElCard>
</div>
</section>
<section class="project-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">{{ module.title }}</h3>
<p class="project-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="project-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="project-homepage-extension__item">
<span class="project-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.project-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--pending,
.project-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--active,
.project-homepage-banner--completed {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--cancelled {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.project-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.project-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.project-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.project-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: 0;
}
.project-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.project-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.project-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.project-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.project-homepage-banner__status-word--pending,
.project-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--active,
.project-homepage-banner__status-word--completed {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--cancelled {
color: transparent;
background: linear-gradient(180deg, rgb(244 63 94 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
text-shadow: 0 10px 24px rgb(244 63 94 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__fact {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.project-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.project-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.project-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.project-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
}
.project-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: 0;
word-break: break-word;
}
.project-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-panel {
overflow: hidden;
}
.project-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.project-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.project-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.project-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.project-homepage-timeline__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.project-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.project-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.project-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.project-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.project-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.project-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.project-homepage-timeline__item:last-child .project-homepage-timeline__line {
opacity: 0;
}
.project-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.project-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.project-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.project-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.project-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.project-homepage-schedule,
.project-homepage-team {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-schedule__progress {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-schedule__progress strong {
color: rgb(15 23 42 / 98%);
font-size: 36px;
line-height: 1.1;
}
.project-homepage-summary-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-summary-metrics__item {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-summary-metrics__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-summary-metrics__value {
color: rgb(15 23 42 / 98%);
font-size: 22px;
line-height: 1.2;
word-break: break-word;
}
.project-homepage-schedule__dates,
.project-homepage-team__roles {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-schedule__date,
.project-homepage-team__role {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.project-homepage-schedule__date strong,
.project-homepage-team__role strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.project-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.project-homepage-extension__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
}
@media (width <= 1280px) {
.project-homepage-banner,
.project-homepage-main,
.project-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.project-homepage-banner {
padding: 18px;
}
.project-homepage-banner__title-row {
flex-wrap: wrap;
}
.project-homepage-banner__title {
font-size: 28px;
}
.project-homepage-banner__status-word {
font-size: 22px;
}
.project-homepage-banner__facts,
.project-homepage-banner__metrics,
.project-homepage-summary-metrics {
grid-template-columns: 1fr;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: none;
}
}
</style>

View File

@@ -9,6 +9,7 @@ import {
RDMS_REQ_PRIORITY_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict'; } from '@/constants/dict';
import { getStatusTagType } from '@/constants/status-tag';
import { import {
fetchChangeProjectRequirementStatus, fetchChangeProjectRequirementStatus,
fetchDeleteProjectRequirement, fetchDeleteProjectRequirement,
@@ -28,7 +29,6 @@ import {
getProjectRequirementActionButtonType, getProjectRequirementActionButtonType,
getProjectRequirementActionDisplayName, getProjectRequirementActionDisplayName,
getProjectRequirementActionIcon, getProjectRequirementActionIcon,
getProjectRequirementStatusTagType,
isProjectRequirementActionTerminal isProjectRequirementActionTerminal
} from './shared/requirement-master-data'; } from './shared/requirement-master-data';
import RequirementActionDialog from './modules/requirement-action-dialog.vue'; import RequirementActionDialog from './modules/requirement-action-dialog.vue';
@@ -377,7 +377,7 @@ const columns = computed(() => [
width: 110, width: 110,
align: 'center', align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => ( formatter: (row: Api.Project.ProjectRequirement) => (
<ElTag type={getProjectRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag> <ElTag type={getStatusTagType('projectRequirement', row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
) )
}, },
{ {

View File

@@ -77,24 +77,6 @@ function resolveActionKeyword(actionCode: string) {
return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword)); return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword));
} }
/**
* 获取项目需求状态的标签颜色
*/
export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Project.ProjectRequirementStatusCode, UI.ThemeColor> = {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
/** /**
* 判断动作是否为终态动作 * 判断动作是否为终态动作
* *

View File

@@ -1,11 +1,11 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { type WorkbenchColumnId, type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules'; import { type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules';
import { buildDefaultLayout } from './workbench-layout-default'; import { buildDefaultLayout } from './workbench-layout-default';
import type { LayoutStorage } from './layout-storage'; import type { LayoutStorage } from './layout-storage';
import { LocalStorageAdapter } from './layout-storage-local'; import { LocalStorageAdapter } from './layout-storage-local';
import { reconcileLayout } from './workbench-layout-reconcile'; import { reconcileLayout } from './workbench-layout-reconcile';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types'; import { WORKBENCH_LAYOUT_VERSION, type WorkbenchGridItem, type WorkbenchLayout } from './workbench-layout-types';
export type WorkbenchMode = 'normal' | 'editing'; export type WorkbenchMode = 'normal' | 'editing';
@@ -56,7 +56,7 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
if (mode.value === 'editing') { if (mode.value === 'editing') {
dirty.value = true; dirty.value = true;
} else { } else {
// 非编辑态写(如折叠)直接落盘 // 非编辑态写直接落盘
persist(); persist();
} }
} }
@@ -91,32 +91,31 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
} }
function hideModule(key: WorkbenchModuleKey) { function hideModule(key: WorkbenchModuleKey) {
for (const col of layout.value.columns) { layout.value.grid = layout.value.grid.filter(item => item.i !== key);
col.modules = col.modules.filter(k => k !== key);
}
if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key); if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key);
markDirty(); markDirty();
} }
function showModule(key: WorkbenchModuleKey, columnId: WorkbenchColumnId = 'left') { function showModule(key: WorkbenchModuleKey) {
if (layout.value.grid.some(item => item.i === key)) return;
layout.value.hidden = layout.value.hidden.filter(k => k !== key); layout.value.hidden = layout.value.hidden.filter(k => k !== key);
const target = layout.value.columns.find(c => c.id === columnId); const meta = getAllModules().find(m => m.key === key);
if (target && !target.modules.includes(key)) target.modules.push(key); if (!meta) return;
const nextY = layout.value.grid.reduce((max, g) => Math.max(max, g.y + g.h), 0);
layout.value.grid.push({
i: key,
x: meta.defaultGrid.x,
y: nextY,
w: meta.defaultGrid.w,
h: meta.defaultGrid.h,
minW: meta.defaultGrid.minW,
minH: meta.defaultGrid.minH
});
markDirty(); markDirty();
} }
function setColumnModules(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) { function updateGrid(grid: WorkbenchGridItem[]) {
const target = layout.value.columns.find(c => c.id === columnId); layout.value.grid = grid;
if (target) target.modules = modules;
markDirty();
}
function toggleCollapse(key: WorkbenchModuleKey) {
if (layout.value.collapsed.includes(key)) {
layout.value.collapsed = layout.value.collapsed.filter(k => k !== key);
} else {
layout.value.collapsed.push(key);
}
markDirty(); markDirty();
} }
@@ -129,15 +128,16 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
} }
async function resetToDefault() { async function resetToDefault() {
layout.value = buildDefaultLayout(getAllModules()); const fresh = buildDefaultLayout(getAllModules());
// 重置只针对布局(位置/尺寸/显隐);用户偏好(如 shortcut.menuKeys原样保留
fresh.settings = { ...layout.value.settings };
layout.value = fresh;
mode.value = 'normal'; mode.value = 'normal';
dirty.value = false; dirty.value = false;
snapshotBeforeEdit = null; snapshotBeforeEdit = null;
await storage.save(options.userId, layout.value); await storage.save(options.userId, layout.value);
} }
const isCollapsed = (key: WorkbenchModuleKey) => layout.value.collapsed.includes(key);
const hiddenMetas = computed(() => { const hiddenMetas = computed(() => {
const allMeta = getAllModules(); const allMeta = getAllModules();
return layout.value.hidden return layout.value.hidden
@@ -152,15 +152,13 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
saving, saving,
error, error,
hiddenMetas, hiddenMetas,
isCollapsed,
load, load,
enterEditing, enterEditing,
saveEditing, saveEditing,
cancelEditing, cancelEditing,
hideModule, hideModule,
showModule, showModule,
setColumnModules, updateGrid,
toggleCollapse,
updateModuleSettings, updateModuleSettings,
resetToDefault resetToDefault
}; };

View File

@@ -11,12 +11,10 @@ export type WorkbenchModuleKey =
| 'myExecution' // B8 · 我负责的执行 | 'myExecution' // B8 · 我负责的执行
| 'productSnapshot' // F24 · 产品深度快照(对象快照 / 当前对象切换) | 'productSnapshot' // F24 · 产品深度快照(对象快照 / 当前对象切换)
| 'teamLoad' // C13 · 团队负载(管理者) | 'teamLoad' // C13 · 团队负载(管理者)
| 'myWeekWorklog' // D16 · 工时(含「我的工时 / 团队工时」两 tab原 C12 teamWorklog 已并入) | 'myWeekWorklog'; // D16 · 工时(含「我的工时 / 团队工时」两 tab原 C12 teamWorklog 已并入)
| 'noticeNotification'; // E22 · 公告 + 通知摘要
// 扩展action动作型 widget、snapshot对象快照型 widget需指定一个对象 // 扩展action动作型 widget、snapshot对象快照型 widget需指定一个对象
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot'; export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot';
export type WorkbenchColumnId = 'left' | 'right';
export interface WorkbenchModuleMeta { export interface WorkbenchModuleMeta {
key: WorkbenchModuleKey; key: WorkbenchModuleKey;
@@ -25,17 +23,17 @@ export interface WorkbenchModuleMeta {
icon: string; icon: string;
category: WorkbenchModuleCategory; category: WorkbenchModuleCategory;
defaultVisible: boolean; defaultVisible: boolean;
defaultColumn: WorkbenchColumnId; /** 默认网格位置与尺寸12 栅格。hidden 项的 x/y 仅作占位show 时动态找空位。 */
defaultOrder: number; defaultGrid: { x: number; y: number; w: number; h: number; minW: number; minH: number };
} }
const placeholder = markRaw({ render: () => null }); const placeholder = markRaw({ render: () => null });
// 默认布局2026-05-27 调整,对应 WORKBENCH_LAYOUT_VERSION=3 // 默认布局2026-06-01 固化用户实拍布局,对应 WORKBENCH_LAYOUT_VERSION=5
// left: myTodo(1) → myExecution(2) // 左列x=0 w=7myTodo(y=0 h=25) → myWeekWorklog(y=25 h=22)
// right: shortcut(1) → myProject(2) → myWeekWorklog(3) → teamLoad(4) // 右列x=7 w=5shortcut(y=0 h=11) → myProject(y=11 h=17) → myExecution(y=28 h=19)
// hidden: projectHealth, noticeNotification, productSnapshot // 底部满宽x=0 w=12teamLoad(y=47 h=16)
// noticeNotification 隐藏原因:公告搬到 banner、通知归全局头部铃铛 // hiddenx/y 为占位show 时动态落到网格底部projectHealth、productSnapshot
const registry: WorkbenchModuleMeta[] = [ const registry: WorkbenchModuleMeta[] = [
{ {
key: 'myTodo', key: 'myTodo',
@@ -44,8 +42,8 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:clipboard-text-clock-outline', icon: 'mdi:clipboard-text-clock-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'left', // minH 24 ≈ 608px保证至少完整展示 5 条待办(头部 124 + 5×71 列表 + 余量)
defaultOrder: 1 defaultGrid: { x: 0, y: 0, w: 7, h: 25, minW: 5, minH: 24 }
}, },
{ {
key: 'myExecution', key: 'myExecution',
@@ -54,8 +52,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:flag-checkered', icon: 'mdi:flag-checkered',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'left', defaultGrid: { x: 7, y: 28, w: 5, h: 19, minW: 4, minH: 15 }
defaultOrder: 2
}, },
{ {
key: 'shortcut', key: 'shortcut',
@@ -64,8 +61,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:rocket-launch-outline', icon: 'mdi:rocket-launch-outline',
category: 'tool', category: 'tool',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 7, y: 0, w: 5, h: 11, minW: 3, minH: 10 }
defaultOrder: 1
}, },
{ {
key: 'myProject', key: 'myProject',
@@ -74,8 +70,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:briefcase-outline', icon: 'mdi:briefcase-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 7, y: 11, w: 5, h: 17, minW: 5, minH: 17 }
defaultOrder: 2
}, },
{ {
key: 'myWeekWorklog', key: 'myWeekWorklog',
@@ -84,8 +79,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:timer-outline', icon: 'mdi:timer-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 0, y: 25, w: 7, h: 22, minW: 6, minH: 18 }
defaultOrder: 3
}, },
{ {
key: 'teamLoad', key: 'teamLoad',
@@ -94,8 +88,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:scale-balance', icon: 'mdi:scale-balance',
category: 'manager', category: 'manager',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 0, y: 47, w: 12, h: 16, minW: 4, minH: 15 }
defaultOrder: 4
}, },
// === 默认隐藏(用户可从 widget 库拖回) === // === 默认隐藏(用户可从 widget 库拖回) ===
{ {
@@ -105,18 +98,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:heart-pulse', icon: 'mdi:heart-pulse',
category: 'manager', category: 'manager',
defaultVisible: false, defaultVisible: false,
defaultColumn: 'right', defaultGrid: { x: 0, y: 0, w: 5, h: 12, minW: 4, minH: 9 }
defaultOrder: 10
},
{
key: 'noticeNotification',
component: placeholder,
displayName: '公告 + 通知',
icon: 'mdi:bullhorn-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 11
}, },
{ {
key: 'productSnapshot', key: 'productSnapshot',
@@ -125,8 +107,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:image-area-close', icon: 'mdi:image-area-close',
category: 'snapshot', category: 'snapshot',
defaultVisible: false, defaultVisible: false,
defaultColumn: 'left', defaultGrid: { x: 0, y: 0, w: 6, h: 14, minW: 4, minH: 10 }
defaultOrder: 41
} }
]; ];

View File

@@ -0,0 +1,30 @@
import { ref } from 'vue';
/**
* 工作台 widget 统一刷新:卡片右上角刷新按钮触发,转 loading + 执行加载动作,并发期内忽略重复点击。
*
* - 已接真实接口的 widget传入 loader内部 await 拉取并回填数据)。
* - 尚未接接口的 mock widget不传 loader转一拍 loading 给出可感知反馈;接口接通后补 loader 即自动生效。
*/
export function useWorkbenchRefresh(loader?: () => Promise<void> | void) {
const loading = ref(false);
async function refresh() {
if (loading.value) return;
loading.value = true;
try {
if (loader) {
await loader();
} else {
// 占位mock widget 无真实数据源,转一拍 loading接口接通后传入 loader 替代
await new Promise<void>(resolve => {
setTimeout(resolve, 400);
});
}
} finally {
loading.value = false;
}
}
return { loading, refresh };
}

View File

@@ -2,29 +2,38 @@ import type { WorkbenchModuleMeta } from './use-workbench-modules';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types'; import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout { export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const left = modules const grid = modules
.filter(m => m.defaultVisible && m.defaultColumn === 'left') .filter(m => m.defaultVisible)
.sort((a, b) => a.defaultOrder - b.defaultOrder) .map(m => ({
.map(m => m.key); i: m.key,
x: m.defaultGrid.x,
y: m.defaultGrid.y,
w: m.defaultGrid.w,
h: m.defaultGrid.h,
minW: m.defaultGrid.minW,
minH: m.defaultGrid.minH
}));
const right = modules const hidden = modules.filter(m => !m.defaultVisible).map(m => m.key);
.filter(m => m.defaultVisible && m.defaultColumn === 'right')
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
const hidden = modules
.filter(m => !m.defaultVisible)
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
return { return {
version: WORKBENCH_LAYOUT_VERSION, version: WORKBENCH_LAYOUT_VERSION,
columns: [ grid,
{ id: 'left', modules: left },
{ id: 'right', modules: right }
],
hidden, hidden,
collapsed: [], // 默认快捷入口(固化用户实拍选择);已有用户的旧 settings 在 load 时优先迁移,此默认仅作用于全新用户
settings: {} settings: {
shortcut: {
menuKeys: [
'product_list',
'project_list',
'ticket_my-submitted',
'personal-center_my-weekly',
'personal-center_my-monthly',
'personal-center_my-performance',
'personal-center_my-application',
'infra_rd-code'
]
}
}
}; };
} }

View File

@@ -1,31 +1,45 @@
import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules'; import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules';
import type { WorkbenchLayout } from './workbench-layout-types'; import type { WorkbenchGridItem, WorkbenchLayout } from './workbench-layout-types';
/** /**
* 把存量布局与当前模块注册中心对齐。 * 把存量布局与当前模块注册中心对齐。
* - 注册中心存在但布局未含的 key按 defaultVisible 进 columns 或 hidden
* - 布局含但注册中心已删除的 key丢弃 * - 布局含但注册中心已删除的 key丢弃
* - 注册中心存在但布局未含的 key按 defaultVisible 落入网格底部或 hidden
*/ */
export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout { export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key)); const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key));
const filterKnown = (list: WorkbenchModuleKey[]) => list.filter(k => knownKeys.has(k)); const metaByKey = new Map<WorkbenchModuleKey, WorkbenchModuleMeta>(modules.map(m => [m.key, m]));
const columns = layout.columns.map(c => ({ id: c.id, modules: filterKnown(c.modules) })); // 最小宽高是组件固有能力下限,始终以 meta 为准刷新(不被旧存储固化),并把 w/h clamp 到不低于下限
const hidden = filterKnown(layout.hidden); const grid: WorkbenchGridItem[] = layout.grid
const collapsed = filterKnown(layout.collapsed); .filter(item => knownKeys.has(item.i))
.map(item => {
const { minW, minH } = metaByKey.get(item.i)!.defaultGrid;
return { ...item, minW, minH, w: Math.max(item.w, minW), h: Math.max(item.h, minH) };
});
const hidden = layout.hidden.filter(k => knownKeys.has(k));
const appearKeys = new Set<WorkbenchModuleKey>([...columns.flatMap(c => c.modules), ...hidden]); const appearKeys = new Set<WorkbenchModuleKey>([...grid.map(g => g.i), ...hidden]);
for (const m of modules) { let nextY = grid.reduce((max, g) => Math.max(max, g.y + g.h), 0);
if (!appearKeys.has(m.key)) {
if (m.defaultVisible) { // 注册中心存在但布局未含的 key可见的落网格底部其余进 hidden
const target = columns.find(c => c.id === m.defaultColumn) ?? columns[0]; for (const m of modules.filter(item => !appearKeys.has(item.key))) {
target.modules.push(m.key); if (m.defaultVisible) {
} else { grid.push({
hidden.push(m.key); i: m.key,
} x: m.defaultGrid.x,
y: nextY,
w: m.defaultGrid.w,
h: m.defaultGrid.h,
minW: m.defaultGrid.minW,
minH: m.defaultGrid.minH
});
nextY += m.defaultGrid.h;
} else {
hidden.push(m.key);
} }
} }
return { ...layout, columns, hidden, collapsed }; return { ...layout, grid, hidden };
} }

View File

@@ -1,8 +1,9 @@
import type { WorkbenchColumnId, WorkbenchModuleKey } from './use-workbench-modules'; import type { WorkbenchModuleKey } from './use-workbench-modules';
// v3 (2026-05-27): myProject 移到右列、myExecution 顶替到 left 第 2 位、noticeNotification 默认隐藏(让位给 banner 公告 + 全局铃铛) // v4 (2026-06-01): 两列排序 → 12 栅格自由网格。columns→grid移除 collapsed
// 版本不匹配时 LocalStorageAdapter.load 直接丢弃存量布局走新默认 // v5 (2026-06-01): 固化用户实拍布局为默认(坐标/尺寸 + 默认快捷入口 menuKeys删除 noticeNotification widget
export const WORKBENCH_LAYOUT_VERSION = 3; // 版本不匹配时丢弃旧布局走新默认settings 原样迁移。
export const WORKBENCH_LAYOUT_VERSION = 5;
export interface WorkbenchShortcutSettings { export interface WorkbenchShortcutSettings {
/** 用户在快捷入口里选了哪些菜单 key */ /** 用户在快捷入口里选了哪些菜单 key */
@@ -15,10 +16,20 @@ export interface WorkbenchModuleSettings {
[key: string]: unknown; [key: string]: unknown;
} }
/** 单个 widget 在 12 栅格中的位置与尺寸。i 即 widget key同时作为 grid-layout-plus 标识)。 */
export interface WorkbenchGridItem {
i: WorkbenchModuleKey;
x: number; // 列起点 0-11
y: number; // 行起点
w: number; // 占列数
h: number; // 占行数
minW?: number;
minH?: number;
}
export interface WorkbenchLayout { export interface WorkbenchLayout {
version: typeof WORKBENCH_LAYOUT_VERSION; version: typeof WORKBENCH_LAYOUT_VERSION;
columns: Array<{ id: WorkbenchColumnId; modules: WorkbenchModuleKey[] }>; grid: WorkbenchGridItem[];
hidden: WorkbenchModuleKey[]; hidden: WorkbenchModuleKey[];
collapsed: WorkbenchModuleKey[];
settings: WorkbenchModuleSettings; settings: WorkbenchModuleSettings;
} }

View File

@@ -10,8 +10,6 @@ export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null;
export type WorkbenchTodoPriority = 'high' | 'mid' | 'low'; export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export type WorkbenchProjectStatus = 'active' | 'preview' | 'paused';
export interface WorkbenchKpiSource { export interface WorkbenchKpiSource {
/** 待办 */ /** 待办 */
todo: { todo: {
@@ -96,28 +94,17 @@ export interface WorkbenchActivityItem extends Omit<WorkbenchActivityItemSource,
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet'; tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
} }
export interface WorkbenchProjectItemSource { /** 「我参与的项目」展示项(由 Api.Project.MyParticipatedProjectItem 衍生) */
export interface WorkbenchParticipatedProjectView {
id: string; id: string;
name: string; name: string;
code: string; code: string | null;
status: WorkbenchProjectStatus; statusName: string | null;
/** 我的角色 */
myRole: string;
/** 进度百分比 0-100 */
progress: number;
/** 我负责的任务数 */
myTaskCount: number;
/** 我负责的待处理任务数 */
myPendingTaskCount: number;
/** 最近活动时间ISO */
lastActiveTime: string;
}
export interface WorkbenchProjectItem extends Omit<WorkbenchProjectItemSource, 'lastActiveTime'> {
statusLabel: string;
statusTone: 'sky' | 'emerald' | 'amber'; statusTone: 'sky' | 'emerald' | 'amber';
myRole: string | null;
progress: number; progress: number;
lastActiveLabel: string; myTaskCount: number;
myPendingTaskCount: number;
} }
const todoCategoryMeta: Record< const todoCategoryMeta: Record<
@@ -144,11 +131,12 @@ const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], Workben
product: 'rose' product: 'rose'
}; };
const projectStatusMeta: Record<WorkbenchProjectStatus, { label: string; tone: WorkbenchProjectItem['statusTone'] }> = { /** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */
active: { label: '进行中', tone: 'emerald' }, function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' {
preview: { label: '试运行', tone: 'sky' }, if (statusCode === 'active') return 'emerald';
paused: { label: '已暂停', tone: 'amber' } if (statusCode === 'paused') return 'amber';
}; return 'sky';
}
function clampPercent(value: number) { function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
@@ -325,61 +313,68 @@ export function buildWorkbenchActivityItems(source: readonly WorkbenchActivityIt
})); }));
} }
export function buildWorkbenchProjectItems(source: readonly WorkbenchProjectItemSource[]): WorkbenchProjectItem[] { export function buildWorkbenchParticipatedProjects(
return source.map(item => { source: readonly Api.Project.MyParticipatedProjectItem[]
const meta = projectStatusMeta[item.status]; ): WorkbenchParticipatedProjectView[] {
return { return source.map(item => ({
...item, id: item.id,
statusLabel: meta.label, name: item.name,
statusTone: meta.tone, code: item.code,
progress: clampPercent(item.progress), statusName: item.statusName,
lastActiveLabel: formatRelative(item.lastActiveTime) statusTone: resolveParticipatedProjectTone(item.statusCode),
} satisfies WorkbenchProjectItem; myRole: item.myRole,
}); progress: clampPercent(item.progress),
myTaskCount: item.myTaskCount,
myPendingTaskCount: item.myPendingTaskCount
}));
} }
export interface WorkbenchOwnedProjectMilestone { /** 「我负责的项目」成员负载展示项 */
id: string; export interface WorkbenchOwnedProjectMemberView {
title: string; userId: string;
timeLabel: string; userName: string | null;
tone: 'amber' | 'slate'; /** 该成员在本项目下进行中任务数 */
activeTaskCount: number;
} }
export interface WorkbenchOwnedProjectMember { /** 「我负责的项目」展示项(由 Api.Project.MyOwnedProjectItem 衍生) */
name: string; export interface WorkbenchOwnedProjectView {
/** 负载 0-100百分比 */
load: number;
level: 'ok' | 'warn' | 'over';
}
export interface WorkbenchOwnedProjectItemSource {
id: string; id: string;
name: string; name: string;
code: string; code: string | null;
/** 进度 0-100 */
progress: number; progress: number;
myRole: string | null;
executionCount: number; executionCount: number;
taskCount: number; taskCount: number;
memberCount: number;
overdueCount: number; overdueCount: number;
/** 距离计划结束剩余天数(负数表示已逾期) */ memberCount: number;
remainingDays: number; /** 计划结束日期 YYYY-MM-DD可空 */
/** 我在该项目中的角色 */ plannedEndDate: string | null;
myRole: string; /** 距计划结束剩余天数(负=已逾期plannedEndDate 为空时 null */
milestones: WorkbenchOwnedProjectMilestone[]; remainingDays: number | null;
members: WorkbenchOwnedProjectMember[]; members: WorkbenchOwnedProjectMemberView[];
} }
export interface WorkbenchOwnedProjectItem extends WorkbenchOwnedProjectItemSource { export function buildWorkbenchOwnedProjects(
progress: number; source: readonly Api.Project.MyOwnedProjectItem[]
} ): WorkbenchOwnedProjectView[] {
export function buildWorkbenchOwnedProjectItems(
source: readonly WorkbenchOwnedProjectItemSource[]
): WorkbenchOwnedProjectItem[] {
return source.map(item => ({ return source.map(item => ({
...item, id: item.id,
progress: clampPercent(item.progress) name: item.name,
code: item.code,
progress: clampPercent(item.progress),
myRole: item.myRole,
executionCount: item.executionCount,
taskCount: item.taskCount,
overdueCount: item.overdueCount,
memberCount: item.memberCount,
plannedEndDate: item.plannedEndDate,
remainingDays: getRemainingDays(item.plannedEndDate),
members: item.members.map(member => ({
userId: member.userId,
userName: member.userName,
activeTaskCount: member.activeTaskCount
}))
})); }));
} }
@@ -759,38 +754,13 @@ export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBar
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) })); return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
} }
export interface WorkbenchMyExecutionItemSource { /**
id: string; * 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。
executionName: string; * 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。
/** 关联项目 */ */
projectId: string; export function buildWorkbenchMyExecutionItems<T extends { statusCode: string; progressRate: number }>(
projectName: string; source: readonly T[]
/** 执行状态编码projectExecution 域pending / active / paused / completed / cancelled */ ): T[] {
statusCode: string;
/** 状态名(后端字典返回) */
statusName: string;
/** 优先级编码(取 RDMS_REQ_PRIORITY_DICT_CODE 字典) */
priority: string;
/** 计划起止 */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止 */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 ID可选 */
projectRequirementId?: string;
/** 关联项目需求名称(可选) */
projectRequirementName?: string;
}
export type WorkbenchMyExecutionItem = WorkbenchMyExecutionItemSource;
/** 过滤掉已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现) */
export function buildWorkbenchMyExecutionItems(
source: readonly WorkbenchMyExecutionItemSource[]
): WorkbenchMyExecutionItem[] {
return source.filter(item => { return source.filter(item => {
if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false; if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false;
if (item.progressRate >= 100) return false; if (item.progressRate >= 100) return false;

View File

@@ -1,15 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import { useWorkbenchStore } from '@/store/modules/workbench'; import { useWorkbenchStore } from '@/store/modules/workbench';
import { import { type WorkbenchModuleKey, useWorkbenchModules } from './composables/use-workbench-modules';
type WorkbenchColumnId, import type { WorkbenchGridItem } from './composables/workbench-layout-types';
type WorkbenchModuleKey,
useWorkbenchModules
} from './composables/use-workbench-modules';
import WorkbenchBanner from './modules/workbench-banner.vue'; import WorkbenchBanner from './modules/workbench-banner.vue';
import WorkbenchColumn from './modules/workbench-column.vue';
import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue'; import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue'; import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
// 保留 6 个 + 重构 2 个key 沿用) // 保留 6 个 + 重构 2 个key 沿用)
@@ -22,11 +19,10 @@ import WorkbenchMyExecution from './modules/workbench-my-execution.vue';
import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue'; import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue';
import WorkbenchTeamLoad from './modules/workbench-team-load.vue'; import WorkbenchTeamLoad from './modules/workbench-team-load.vue';
import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue'; import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue';
import WorkbenchNoticeNotification from './modules/workbench-notice-notification.vue';
defineOptions({ name: 'Workbench' }); defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules(); const { registerModuleComponent, getModuleMeta } = useWorkbenchModules();
// 保留 6 个 + 重构 2 个 // 保留 6 个 + 重构 2 个
registerModuleComponent('myTodo', WorkbenchTodoPanel); registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myProject', WorkbenchProjectGrid); registerModuleComponent('myProject', WorkbenchProjectGrid);
@@ -37,7 +33,6 @@ registerModuleComponent('myExecution', WorkbenchMyExecution);
registerModuleComponent('productSnapshot', WorkbenchProductSnapshot); registerModuleComponent('productSnapshot', WorkbenchProductSnapshot);
registerModuleComponent('teamLoad', WorkbenchTeamLoad); registerModuleComponent('teamLoad', WorkbenchTeamLoad);
registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog); registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog);
registerModuleComponent('noticeNotification', WorkbenchNoticeNotification);
const workbench = useWorkbenchStore(); const workbench = useWorkbenchStore();
const libraryOpen = ref(false); const libraryOpen = ref(false);
@@ -65,8 +60,10 @@ watch(
} }
); );
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) { const editing = computed(() => workbench.mode === 'editing');
workbench.setColumnModules(columnId, modules);
function onGridUpdated(grid: WorkbenchGridItem[]) {
workbench.updateGrid(grid);
} }
async function handleReset() { async function handleReset() {
@@ -107,30 +104,51 @@ onBeforeRouteLeave(async (_to, _from, next) => {
@open-library="libraryOpen = true" @open-library="libraryOpen = true"
/> />
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块"> <ElEmpty v-if="workbench.layout.grid.length === 0" description="还没有可见模块">
<ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton> <ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton>
</ElEmpty> </ElEmpty>
<section v-else class="workbench__main"> <div v-else class="workbench__main">
<WorkbenchColumn <GridLayout
v-for="col in workbench.layout.columns" :layout="workbench.layout.grid"
:key="col.id" :col-num="12"
:column-id="col.id" :row-height="10"
:modules="col.modules" :margin="[16, 16]"
:editing="workbench.mode === 'editing'" :is-draggable="editing"
:collapsed="workbench.layout.collapsed" :is-resizable="editing"
@update:modules="onColumnUpdate(col.id, $event)" :vertical-compact="true"
@hide="workbench.hideModule" :use-css-transforms="true"
@toggle-collapse="workbench.toggleCollapse" @layout-updated="onGridUpdated"
/> >
</section> <GridItem
v-for="item in workbench.layout.grid"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
:min-w="item.minW"
:min-h="item.minH"
drag-allow-from=".module-card__head"
>
<component
:is="getModuleMeta(item.i)?.component"
:module-key="item.i"
:editing="editing"
@hide="workbench.hideModule(item.i as WorkbenchModuleKey)"
@open-settings="() => {}"
/>
</GridItem>
</GridLayout>
</div>
<WorkbenchModuleLibrary <WorkbenchModuleLibrary
v-model="libraryOpen" v-model="libraryOpen"
:hidden-metas="workbench.hiddenMetas" :hidden-metas="workbench.hiddenMetas"
@add-module=" @add-module="
(key, col) => { key => {
workbench.showModule(key, col); workbench.showModule(key);
libraryOpen = false; libraryOpen = false;
} }
" "
@@ -143,15 +161,9 @@ onBeforeRouteLeave(async (_to, _from, next) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-x: auto;
} }
.workbench__main { .workbench__main {
display: grid; min-width: 1100px;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 1280px) {
.workbench__main {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@@ -2,12 +2,9 @@ import dayjs from 'dayjs';
import type { import type {
WorkbenchActivityItemSource, WorkbenchActivityItemSource,
WorkbenchKpiSource, WorkbenchKpiSource,
WorkbenchMyExecutionItemSource,
WorkbenchMyWeekWorklogSource, WorkbenchMyWeekWorklogSource,
WorkbenchOwnedProjectItemSource,
WorkbenchProgressBarSource, WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource, WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamLoadSource, WorkbenchTeamLoadSource,
WorkbenchTeamWorklogSource, WorkbenchTeamWorklogSource,
WorkbenchTodoItemSource WorkbenchTodoItemSource
@@ -208,245 +205,6 @@ export const workbenchActivityMock = [
} }
] satisfies WorkbenchActivityItemSource[]; ] satisfies WorkbenchActivityItemSource[];
export const workbenchMyExecutionMock = [
// 商城 V2 升级 · 3 条(分组测试主项目)
{
id: 'exec-1',
executionName: '迭代 24.05 · 后端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(10, 'day').startOf('day')),
plannedEndDate: iso(now.add(3, 'day').endOf('day')),
actualStartDate: iso(now.subtract(8, 'day').startOf('day')),
actualEndDate: null,
progressRate: 68,
projectRequirementId: 'req-mall-001',
projectRequirementName: '订单履约后端拆分(一期)'
},
{
id: 'exec-2',
executionName: '会员等级提示文案',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '3',
plannedStartDate: iso(now.subtract(4, 'day').startOf('day')),
plannedEndDate: iso(now.add(6, 'day').endOf('day')),
actualStartDate: iso(now.subtract(3, 'day').startOf('day')),
actualEndDate: null,
progressRate: 25,
projectRequirementId: 'req-mall-002',
projectRequirementName: '会员等级 UI 升级'
},
{
id: 'exec-3',
executionName: '订单退款流程拆分',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'paused',
statusName: '已暂停',
priority: '2',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.add(10, 'day').endOf('day')),
actualStartDate: iso(now.subtract(15, 'day').startOf('day')),
actualEndDate: null,
progressRate: 50
},
// 风控引擎 · 2 条(含一条计划已过期)
{
id: 'exec-4',
executionName: '关键路径优化',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(2, 'day').endOf('day')),
actualStartDate: iso(now.subtract(18, 'day').startOf('day')),
actualEndDate: null,
progressRate: 42,
projectRequirementId: 'req-risk-001',
projectRequirementName: '风控决策链路压缩'
},
{
id: 'exec-5',
executionName: '黑名单规则改造',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'pending',
statusName: '待开始',
priority: '3',
plannedStartDate: iso(now.add(5, 'day').startOf('day')),
plannedEndDate: iso(now.add(20, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0
},
// 收银台 V3 · 1 条
{
id: 'exec-6',
executionName: '多币种支持 · 计算引擎',
projectId: 'prj-cashier',
projectName: '收银台 V3',
statusCode: 'pending',
statusName: '待开始',
priority: '2',
plannedStartDate: iso(now.add(2, 'day').startOf('day')),
plannedEndDate: iso(now.add(15, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
projectRequirementId: 'req-cashier-001',
projectRequirementName: '多币种结算(含汇率快照)'
},
// 订单中心 · 1 条
{
id: 'exec-7',
executionName: '订单导出 V2',
projectId: 'prj-order',
projectName: '订单中心',
statusCode: 'active',
statusName: '进行中',
priority: '4',
plannedStartDate: iso(now.subtract(15, 'day').startOf('day')),
plannedEndDate: iso(now.add(7, 'day').endOf('day')),
actualStartDate: iso(now.subtract(12, 'day').startOf('day')),
actualEndDate: null,
progressRate: 35
},
// 已完成 —— builder 应过滤掉
{
id: 'exec-8',
executionName: '上一迭代 · 前端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'completed',
statusName: '已完成',
priority: '3',
plannedStartDate: iso(now.subtract(40, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(15, 'day').endOf('day')),
actualStartDate: iso(now.subtract(38, 'day').startOf('day')),
actualEndDate: iso(now.subtract(14, 'day').endOf('day')),
progressRate: 100
},
// 已取消 —— builder 应过滤掉
{
id: 'exec-9',
executionName: '促销活动 · 春节专题',
projectId: 'prj-marketing',
projectName: '营销中台',
statusCode: 'cancelled',
statusName: '已取消',
priority: '3',
plannedStartDate: iso(now.subtract(30, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(10, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 15
},
// 进度 100 但状态未扭转 —— builder 应过滤掉
{
id: 'exec-10',
executionName: '风控规则升级(待扭转)',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '2',
plannedStartDate: iso(now.subtract(8, 'day').startOf('day')),
plannedEndDate: iso(now.add(1, 'day').endOf('day')),
actualStartDate: iso(now.subtract(6, 'day').startOf('day')),
actualEndDate: null,
progressRate: 100
}
] satisfies WorkbenchMyExecutionItemSource[];
export const workbenchOwnedProjectMock = [
{
id: 'p1',
name: '商城 V2 升级',
code: 'MALL-V2',
progress: 70,
executionCount: 5,
taskCount: 32,
memberCount: 6,
overdueCount: 1,
remainingDays: 12,
myRole: '项目负责人',
milestones: [
{ id: 'm1', title: 'SSO 改造提测', timeLabel: '今日 18:00', tone: 'amber' },
{ id: 'm2', title: '迭代 24.05 关闭', timeLabel: '今日', tone: 'amber' },
{ id: 'm3', title: '多币种支持评审', timeLabel: '05-26', tone: 'slate' }
],
members: [
{ name: '张三', load: 50, level: 'ok' },
{ name: '李四', load: 30, level: 'ok' },
{ name: '王五', load: 90, level: 'over' }
]
},
{
id: 'p2',
name: '风控引擎接入',
code: 'RISK-ENGINE',
progress: 45,
executionCount: 3,
taskCount: 18,
memberCount: 4,
overdueCount: 2,
remainingDays: 30,
myRole: '项目负责人',
milestones: [
{ id: 'm4', title: '分片设计评审', timeLabel: '明日', tone: 'amber' },
{ id: 'm5', title: '缓存穿透优化交付', timeLabel: '05-28', tone: 'slate' }
],
members: [
{ name: '李四', load: 30, level: 'ok' },
{ name: '钱七', load: 65, level: 'warn' }
]
}
] satisfies WorkbenchOwnedProjectItemSource[];
export const workbenchProjectMock = [
{
id: 'prj-1',
name: '收银台 V3',
code: 'CASHIER-V3',
status: 'active',
myRole: '项目负责人',
progress: 72,
myTaskCount: 6,
myPendingTaskCount: 2,
lastActiveTime: iso(now.subtract(35, 'minute'))
},
{
id: 'prj-2',
name: '会员中心',
code: 'MEMBER',
status: 'active',
myRole: '后端负责人',
progress: 58,
myTaskCount: 4,
myPendingTaskCount: 1,
lastActiveTime: iso(now.subtract(3, 'hour'))
},
{
id: 'prj-3',
name: '订单中心',
code: 'ORDER-CENTER',
status: 'preview',
myRole: '产品经理',
progress: 95,
myTaskCount: 4,
myPendingTaskCount: 0,
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
}
] satisfies WorkbenchProjectItemSource[];
const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD'); const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD');
const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD'); const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD');

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { VueDraggable } from 'vue-draggable-plus';
import type { WorkbenchColumnId, WorkbenchModuleKey } from '../composables/use-workbench-modules';
import { useWorkbenchModules } from '../composables/use-workbench-modules';
interface Props {
columnId: WorkbenchColumnId;
modules: WorkbenchModuleKey[];
editing: boolean;
collapsed: WorkbenchModuleKey[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modules', modules: WorkbenchModuleKey[]): void;
(e: 'hide', key: WorkbenchModuleKey): void;
(e: 'toggle-collapse', key: WorkbenchModuleKey): void;
(e: 'open-settings', key: WorkbenchModuleKey): void;
}>();
const { getModuleMeta } = useWorkbenchModules();
const modelValue = computed({
get: () => props.modules,
set: (val: WorkbenchModuleKey[]) => emit('update:modules', val)
});
</script>
<template>
<VueDraggable
v-model="modelValue"
group="workbench-modules"
:animation="180"
handle=".module-drag-handle"
:disabled="!editing"
class="workbench-column"
>
<template v-for="key in modelValue" :key="key">
<component
:is="getModuleMeta(key)?.component"
:module-key="key"
:editing="editing"
:collapsed="collapsed.includes(key)"
@hide="emit('hide', key)"
@toggle-collapse="emit('toggle-collapse', key)"
@open-settings="emit('open-settings', key)"
/>
</template>
</VueDraggable>
</template>
<style scoped>
.workbench-column {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 200px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject } from 'vue'; import { inject } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' }); defineOptions({ name: 'WorkbenchModuleCard' });
@@ -12,31 +12,25 @@ interface Props {
icon?: string; icon?: string;
badgeCount?: number; badgeCount?: number;
editing?: boolean; editing?: boolean;
collapsed?: boolean;
hasSettings?: boolean; hasSettings?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
icon: undefined, icon: undefined,
badgeCount: undefined, badgeCount: undefined,
editing: false, editing: false,
collapsed: false,
hasSettings: false hasSettings: false
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle-collapse'): void;
(e: 'hide'): void; (e: 'hide'): void;
(e: 'open-settings'): void; (e: 'open-settings'): void;
(e: 'refresh'): void; (e: 'refresh'): void;
(e: 'navigate'): void;
}>(); }>();
const showBody = computed(() => !props.collapsed);
</script> </script>
<template> <template>
<section class="module-card" :class="{ 'is-editing': editing, 'is-collapsed': collapsed }"> <section class="module-card" :class="{ 'is-editing': editing }">
<header class="module-card__head"> <header class="module-card__head">
<span v-if="editing" class="module-drag-handle" title="拖动调整位置"> <span v-if="editing" class="module-drag-handle" title="拖动调整位置">
<SvgIcon icon="mdi:drag-vertical" /> <SvgIcon icon="mdi:drag-vertical" />
@@ -49,21 +43,9 @@ const showBody = computed(() => !props.collapsed);
<ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')"> <ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')">
<SvgIcon icon="mdi:cog-outline" /> <SvgIcon icon="mdi:cog-outline" />
</ElButton> </ElButton>
<ElButton
v-if="!editing"
link
size="small"
:title="collapsed ? '展开' : '折叠'"
@click="emit('toggle-collapse')"
>
<SvgIcon :icon="collapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" />
</ElButton>
<ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')"> <ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')">
<SvgIcon icon="mdi:refresh" /> <SvgIcon icon="mdi:refresh" />
</ElButton> </ElButton>
<ElButton v-if="!editing" link size="small" title="跳详情" @click="emit('navigate')">
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
<ElButton v-if="!editing && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()"> <ElButton v-if="!editing && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()">
<SvgIcon icon="mdi:view-dashboard-edit-outline" /> <SvgIcon icon="mdi:view-dashboard-edit-outline" />
</ElButton> </ElButton>
@@ -73,7 +55,7 @@ const showBody = computed(() => !props.collapsed);
</div> </div>
</header> </header>
<div v-show="showBody" class="module-card__body"> <div class="module-card__body">
<slot /> <slot />
</div> </div>
</section> </section>
@@ -84,7 +66,7 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-bg-color); background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 10px; border-radius: 10px;
min-height: 180px; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -98,10 +80,6 @@ const showBody = computed(() => !props.collapsed);
border-color: var(--el-color-primary-light-5); border-color: var(--el-color-primary-light-5);
} }
.module-card.is-collapsed {
min-height: auto;
}
.module-card__head { .module-card__head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -111,10 +89,6 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-fill-color-blank); background: var(--el-fill-color-blank);
} }
.module-card.is-collapsed .module-card__head {
border-bottom: none;
}
.module-drag-handle { .module-drag-handle {
cursor: grab; cursor: grab;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -153,7 +127,10 @@ const showBody = computed(() => !props.collapsed);
.module-card__body { .module-card__body {
flex: 1; flex: 1;
min-height: 0;
padding: 14px; padding: 14px;
overflow: auto; overflow: auto;
display: flex;
flex-direction: column;
} }
</style> </style>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { import type { WorkbenchModuleCategory, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
interface Props { interface Props {
modelValue: boolean; modelValue: boolean;
@@ -13,7 +9,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void; (e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void; (e: 'add-module', key: WorkbenchModuleMeta['key']): void;
}>(); }>();
// 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具 // 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具
@@ -57,18 +53,13 @@ const groups = computed<Array<{ key: LibraryGroupKey; label: string; items: Work
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<template #default> <template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p> <p class="hint">点击下方模块加入工作台落到网格底部可在编辑态拖动调整</p>
<div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div> <div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div>
<div v-else class="library"> <div v-else class="library">
<section v-for="group in groups" :key="group.key" class="library-group"> <section v-for="group in groups" :key="group.key" class="library-group">
<h4 class="library-group__title">{{ group.label }}</h4> <h4 class="library-group__title">{{ group.label }}</h4>
<ul class="library-group__list"> <ul class="library-group__list">
<li <li v-for="meta in group.items" :key="meta.key" class="library-item" @click="emit('add-module', meta.key)">
v-for="meta in group.items"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<SvgIcon :icon="meta.icon" /> <SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span> <span class="library-item__name">{{ meta.displayName }}</span>
</li> </li>

View File

@@ -1,30 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context'; import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyExecutionPage } from '@/service/api';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared'; import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared';
import { type WorkbenchMyExecutionItem, buildWorkbenchMyExecutionItems } from '../homepage'; import { buildWorkbenchMyExecutionItems } from '../homepage';
import { workbenchMyExecutionMock } from '../mock'; import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' }); defineOptions({ name: 'WorkbenchMyExecution' });
type MyExecutionItem = Api.Project.MyExecutionItem;
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const router = useRouter(); const router = useRouter();
const items = computed(() => buildWorkbenchMyExecutionItems(workbenchMyExecutionMock)); const items = ref<MyExecutionItem[]>([]);
const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉取全部当前用户负责的进行中执行;状态/进度过滤由后端完成
const { data, error } = await fetchGetMyExecutionPage({ pageNo: 1, pageSize: -1 });
if (error) return;
items.value = buildWorkbenchMyExecutionItems(data?.list ?? []);
});
onMounted(refresh);
// 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前) // 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前)
const groups = computed<Array<{ projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>>(() => { const groups = computed<Array<{ projectId: string; projectName: string; items: MyExecutionItem[] }>>(() => {
const map = new Map<string, { projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>(); const map = new Map<string, { projectId: string; projectName: string; items: MyExecutionItem[] }>();
items.value.forEach(item => { items.value.forEach(item => {
if (!map.has(item.projectId)) { if (!map.has(item.projectId)) {
map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] }); map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] });
@@ -42,6 +53,26 @@ const groups = computed<Array<{ projectId: string; projectName: string; items: W
return groupsArr.sort((a, b) => b.items.length - a.items.length); return groupsArr.sort((a, b) => b.items.length - a.items.length);
}); });
// 手风琴:单开,默认展开第一个项目(执行最多);展开项消失时回退到第一个
const expandedProjectId = ref<string>('');
watch(
groups,
list => {
if (!list.length) {
expandedProjectId.value = '';
return;
}
if (!list.some(g => g.projectId === expandedProjectId.value)) {
expandedProjectId.value = list[0].projectId;
}
},
{ immediate: true }
);
function toggleProject(projectId: string) {
expandedProjectId.value = expandedProjectId.value === projectId ? '' : projectId;
}
function goProjectExecutionPool(projectId: string) { function goProjectExecutionPool(projectId: string) {
router.push({ router.push({
path: '/project/project/execution', path: '/project/project/execution',
@@ -49,7 +80,7 @@ function goProjectExecutionPool(projectId: string) {
}); });
} }
function goRequirementDetail(item: WorkbenchMyExecutionItem) { function goRequirementDetail(item: MyExecutionItem) {
if (!item.projectRequirementId) return; if (!item.projectRequirementId) return;
router.push({ router.push({
path: '/project/project/requirement', path: '/project/project/requirement',
@@ -67,27 +98,43 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
icon="mdi:flag-checkered" icon="mdi:flag-checkered"
:badge-count="items.length" :badge-count="items.length"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div v-if="items.length" class="exec-groups"> <div v-if="items.length" v-loading="loading" class="exec-groups">
<section v-for="group in groups" :key="group.projectId" class="exec-group"> <section
<header class="exec-group__head"> v-for="group in groups"
:key="group.projectId"
class="exec-group"
:class="{ 'is-open': expandedProjectId === group.projectId }"
>
<header
class="exec-group__head"
role="button"
tabindex="0"
:aria-expanded="expandedProjectId === group.projectId"
@click="toggleProject(group.projectId)"
@keydown.enter.prevent="toggleProject(group.projectId)"
>
<SvgIcon
icon="mdi:chevron-right"
class="exec-group__chevron"
:class="{ 'is-open': expandedProjectId === group.projectId }"
/>
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" /> <SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
<span <span class="exec-group__name" :title="group.projectName">{{ group.projectName }}</span>
class="exec-group__name"
role="button"
tabindex="0"
:title="`进入「${group.projectName}」执行池`"
@click="goProjectExecutionPool(group.projectId)"
@keydown.enter.prevent="goProjectExecutionPool(group.projectId)"
>
{{ group.projectName }}
</span>
<span class="exec-group__count">{{ group.items.length }}</span> <span class="exec-group__count">{{ group.items.length }}</span>
<ElButton
link
size="small"
class="exec-group__go"
:title="`进入「${group.projectName}」执行池`"
@click.stop="goProjectExecutionPool(group.projectId)"
>
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
</header> </header>
<ul class="exec-list"> <ul v-show="expandedProjectId === group.projectId" class="exec-list">
<li v-for="item in group.items" :key="item.id" class="exec-item"> <li v-for="item in group.items" :key="item.id" class="exec-item">
<div class="exec-head"> <div class="exec-head">
<span class="exec-name" :title="item.executionName">{{ item.executionName }}</span> <span class="exec-name" :title="item.executionName">{{ item.executionName }}</span>
@@ -122,6 +169,7 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</div> </div>
<div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row"> <div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row">
<SvgIcon icon="mdi:link-variant" class="exec-meta__icon" /> <SvgIcon icon="mdi:link-variant" class="exec-meta__icon" />
<span class="exec-meta__label">需求</span>
<span <span
class="exec-meta__text exec-meta__link" class="exec-meta__text exec-meta__link"
role="button" role="button"
@@ -139,28 +187,66 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</ul> </ul>
</section> </section>
</div> </div>
<ElEmpty v-else description="暂无进行中的执行" :image-size="60" /> <div v-else v-loading="loading" class="exec-empty">
<ElEmpty description="暂无进行中的执行" :image-size="60" />
</div>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
<style scoped> <style scoped>
.exec-groups { .exec-groups {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 6px;
}
.exec-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
} }
.exec-group__head { .exec-group__head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-bottom: 8px; padding: 8px 10px;
padding: 0 2px 6px; border-radius: 6px;
border-bottom: 1px dashed var(--el-border-color-lighter); /* 常驻分组底色,明显区别于下方执行项卡片 */
background: var(--el-fill-color-light);
border-left: 3px solid transparent;
cursor: pointer;
user-select: none;
transition:
background 0.16s ease,
border-color 0.16s ease;
}
.exec-group__head:hover,
.exec-group__head:focus-visible {
background: var(--el-fill-color);
outline: none;
}
.exec-group.is-open > .exec-group__head {
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.exec-group__chevron {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
transition: transform 0.18s ease;
}
.exec-group__chevron.is-open {
transform: rotate(90deg);
} }
.exec-group__icon { .exec-group__icon {
flex-shrink: 0; flex-shrink: 0;
font-size: 14px; font-size: 15px;
color: var(--el-text-color-secondary); /* 项目 icon 用主色,与项目管理业务域图标一致且更醒目 */
color: var(--el-color-primary);
} }
.exec-group__name { .exec-group__name {
flex: 1; flex: 1;
@@ -168,17 +254,16 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 13px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
color: var(--el-text-color-regular); color: var(--el-text-color-primary);
letter-spacing: 0.01em; letter-spacing: 0.01em;
cursor: pointer;
transition: color 0.16s ease;
} }
.exec-group__name:hover, .exec-group.is-open .exec-group__name {
.exec-group__name:focus-visible {
color: var(--el-color-primary); color: var(--el-color-primary);
outline: none; }
.exec-group__go {
flex-shrink: 0;
} }
.exec-group__count { .exec-group__count {
flex-shrink: 0; flex-shrink: 0;
@@ -195,8 +280,8 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
} }
.exec-list { .exec-list {
list-style: none; list-style: none;
margin: 0; margin: 4px 0 2px;
padding: 0; padding: 0 2px 0 22px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -252,6 +337,10 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
font-size: 13px; font-size: 13px;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
} }
.exec-meta__label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.exec-meta__text { .exec-meta__text {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View File

@@ -13,19 +13,21 @@ import {
buildWorkbenchWeekWorklogView buildWorkbenchWeekWorklogView
} from '../homepage'; } from '../homepage';
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock'; import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' }); defineOptions({ name: 'WorkbenchMyWeekWorklog' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const router = useRouter(); const router = useRouter();
const { loading, refresh } = useWorkbenchRefresh();
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。 // EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。 // 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) { function resolveIsoWeekStart(weekDate: Date | null) {
@@ -302,12 +304,12 @@ watch(activeTab, async tab => {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="工时" title="工时"
icon="mdi:timer-outline" icon="mdi:timer-outline"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="ww-tabbar"> <div class="ww-tabbar">
<ElTabs v-model="activeTab" class="ww-tabs"> <ElTabs v-model="activeTab" class="ww-tabs">
@@ -327,7 +329,7 @@ watch(activeTab, async tab => {
</div> </div>
<!-- ============ 我的工时 tab ============ --> <!-- ============ 我的工时 tab ============ -->
<div v-show="activeTab === 'my'"> <div v-show="activeTab === 'my'" class="ww-tab-content">
<template v-if="myView"> <template v-if="myView">
<div class="ww-headline"> <div class="ww-headline">
<div class="ww-section-title"> <div class="ww-section-title">
@@ -375,7 +377,7 @@ watch(activeTab, async tab => {
</div> </div>
<!-- ============ 团队工时 tab ============ --> <!-- ============ 团队工时 tab ============ -->
<div v-show="activeTab === 'team'"> <div v-show="activeTab === 'team'" class="ww-tab-content">
<template v-if="teamView"> <template v-if="teamView">
<div class="tw-kpis"> <div class="tw-kpis">
<div class="tw-kpi"> <div class="tw-kpi">
@@ -444,6 +446,19 @@ watch(activeTab, async tab => {
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
/* tab 内容区填充剩余高度flex 列布局,图表区自适应撑满,不写死高度、不内部滚动 */
.ww-tab-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ww-tab-content :deep(.el-empty) {
margin: auto;
} }
.ww-tabs { .ww-tabs {
flex: 1; flex: 1;
@@ -468,12 +483,15 @@ watch(activeTab, async tab => {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 10px; margin-bottom: 10px;
flex-shrink: 0;
} }
.ww-grid { .ww-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px; gap: 16px;
flex: 1;
min-height: 0;
} }
@media (width <= 520px) { @media (width <= 520px) {
.ww-grid { .ww-grid {
@@ -488,6 +506,7 @@ watch(activeTab, async tab => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
} }
.ww-section-title { .ww-section-title {
@@ -508,7 +527,8 @@ watch(activeTab, async tab => {
.ww-pie-wrap { .ww-pie-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
height: 280px; flex: 1;
min-height: 0;
} }
.ww-pie { .ww-pie {
width: 100%; width: 100%;
@@ -517,7 +537,8 @@ watch(activeTab, async tab => {
.ww-bar { .ww-bar {
width: 100%; width: 100%;
height: 280px; flex: 1;
min-height: 0;
} }
.ww-bar-legend { .ww-bar-legend {
display: flex; display: flex;
@@ -526,6 +547,7 @@ watch(activeTab, async tab => {
margin-top: 8px; margin-top: 8px;
font-size: 11px; font-size: 11px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
flex-shrink: 0;
} }
.ww-bar-legend__item { .ww-bar-legend__item {
display: inline-flex; display: inline-flex;
@@ -547,6 +569,7 @@ watch(activeTab, async tab => {
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
font-size: 13px; font-size: 13px;
flex-shrink: 0;
} }
.ww-footer b { .ww-footer b {
font-weight: 700; font-weight: 700;
@@ -570,6 +593,7 @@ watch(activeTab, async tab => {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
flex-shrink: 0;
} }
.tw-kpi { .tw-kpi {
display: flex; display: flex;
@@ -609,7 +633,8 @@ watch(activeTab, async tab => {
.tw-bar { .tw-bar {
width: 100%; width: 100%;
height: 240px; flex: 1;
min-height: 0;
} }
.tw-footer { .tw-footer {
@@ -619,6 +644,7 @@ watch(activeTab, async tab => {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
flex-shrink: 0;
} }
.tw-footer b { .tw-footer b {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);

View File

@@ -1,293 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchNoticeNotification' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface NoticeRow {
id: string;
title: string;
timeLabel: string;
}
interface NotificationRow {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const notices: NoticeRow[] = [
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' }
];
const notifications: NotificationRow[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前', unread: true },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前', unread: true },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日', unread: false }
];
const unreadCount = computed(() => notifications.filter(n => n.unread).length);
// mock 阶段:交互函数留占位,等后端接口落地后接通
function handleOpenNotification(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 跳对应业务对象详情
// eslint-disable-next-line no-console
console.warn('[notification] open', row.id);
}
function handleMarkRead(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 调标已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', row.id);
}
function handleMarkAllRead() {
// eslint-disable-next-line no-warning-comments
// TODO: 调一键全部已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
</script>
<template>
<WorkbenchModuleCard
title="公告与通知"
icon="mdi:bullhorn-outline"
:badge-count="unreadCount || undefined"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<!-- 1/3公告只读露头扫一眼 -->
<section class="nn-col nn-col--notice">
<header class="nn-h">
<SvgIcon icon="mdi:bullhorn-outline" class="nn-h__icon" />
<span class="nn-h__title">公告</span>
<span class="nn-h__count">{{ notices.length }}</span>
</header>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id" class="nn-notice">
<div class="nn-notice__title">{{ row.title }}</div>
<div class="nn-notice__time">{{ row.timeLabel }}</div>
</li>
</ul>
</section>
<!-- 2/3通知可操作按行跳详情/标已读 -->
<section class="nn-col nn-col--notify">
<header class="nn-h">
<SvgIcon icon="mdi:bell-outline" class="nn-h__icon" />
<span class="nn-h__title">通知</span>
<span v-if="unreadCount > 0" class="nn-h__count is-unread">未读 {{ unreadCount }}</span>
<span v-else class="nn-h__count">{{ notifications.length }}</span>
<ElButton v-if="unreadCount > 0" link size="small" class="nn-h__action" @click="handleMarkAllRead">
全部已读
</ElButton>
</header>
<ul class="nn-list">
<li
v-for="row in notifications"
:key="row.id"
class="nn-notify"
:class="{ 'is-unread': row.unread }"
@click="handleOpenNotification(row)"
>
<span v-if="row.unread" class="nn-notify__dot" />
<span class="nn-notify__title">{{ row.title }}</span>
<span class="nn-notify__time">{{ row.timeLabel }}</span>
<span class="nn-notify__actions">
<ElTooltip v-if="row.unread" content="标为已读" placement="top">
<button class="nn-notify__act" @click.stop="handleMarkRead(row)">
<SvgIcon icon="mdi:check" />
</button>
</ElTooltip>
<ElTooltip content="跳详情" placement="top">
<button class="nn-notify__act" @click.stop="handleOpenNotification(row)">
<SvgIcon icon="mdi:open-in-new" />
</button>
</ElTooltip>
</span>
</li>
</ul>
</section>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
.nn-col {
min-width: 0;
}
.nn-h {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
}
.nn-h__icon {
color: var(--el-color-primary);
font-size: 14px;
}
.nn-h__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.nn-h__count {
padding: 1px 7px;
border-radius: 999px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 1.5;
}
.nn-h__count.is-unread {
background: var(--el-color-danger);
color: #fff;
font-weight: 600;
}
.nn-h__action {
margin-left: auto;
font-size: 11px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.nn-list::-webkit-scrollbar {
width: 6px;
}
.nn-list::-webkit-scrollbar-thumb {
background: var(--el-fill-color-darker);
border-radius: 3px;
}
.nn-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
/* 公告行:纯阅读 + 标题 2 行 clamp */
.nn-notice {
padding: 7px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.nn-notice:last-child {
border-bottom: none;
}
.nn-notice__title {
font-size: 12.5px;
line-height: 1.5;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.nn-notice__time {
margin-top: 3px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 通知行:可操作 + hover 浮出动作按钮 */
.nn-notify {
display: grid;
grid-template-columns: 8px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 8px;
margin: 0 -8px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 120ms;
}
.nn-notify + .nn-notify {
border-top: 1px dashed var(--el-border-color-lighter);
}
.nn-notify:hover {
background: var(--el-fill-color-light);
}
.nn-notify__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary);
}
.nn-notify:not(.is-unread) .nn-notify__dot {
background: transparent;
}
.nn-notify__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--el-text-color-regular);
}
.nn-notify.is-unread .nn-notify__title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.nn-notify__time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
white-space: nowrap;
}
.nn-notify__actions {
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 120ms;
}
.nn-notify:hover .nn-notify__actions {
opacity: 1;
}
.nn-notify__act {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 13px;
transition: background-color 120ms;
}
.nn-notify__act:hover {
background: var(--el-fill-color);
color: var(--el-color-primary);
}
</style>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProductSnapshot' }); defineOptions({ name: 'WorkbenchProductSnapshot' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
interface ProductOption { interface ProductOption {
id: string; id: string;
@@ -69,12 +71,12 @@ function onChange(id: string) {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="产品深度快照" title="产品深度快照"
icon="mdi:image-area-close" icon="mdi:image-area-close"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="ps-head"> <div class="ps-head">
<span class="ps-pin-label">当前产品</span> <span class="ps-pin-label">当前产品</span>

View File

@@ -1,22 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { fetchGetMyOwnedProjectPage, fetchGetMyParticipatedProjectPage } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchOwnedProjectItems, buildWorkbenchProjectItems } from '../homepage'; import {
import { workbenchOwnedProjectMock, workbenchProjectMock } from '../mock'; type WorkbenchOwnedProjectView,
type WorkbenchParticipatedProjectView,
buildWorkbenchOwnedProjects,
buildWorkbenchParticipatedProjects
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' }); defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ defineEmits<{
(e: 'hide'): void; (e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>(); }>();
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();
@@ -25,10 +29,26 @@ type ProjectViewKey = 'participated' | 'owned';
const activeView = ref<ProjectViewKey>('participated'); const activeView = ref<ProjectViewKey>('participated');
const participatedItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock)); const participatedItems = ref<WorkbenchParticipatedProjectView[]>([]);
const ownedItems = computed(() => buildWorkbenchOwnedProjectItems(workbenchOwnedProjectMock)); const ownedItems = ref<WorkbenchOwnedProjectView[]>([]);
const currentOwnedId = ref<string>(ownedItems.value[0]?.id ?? ''); const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉全部;列表已由后端按"进行中 + 创建时间升序"过滤排序
const [participated, owned] = await Promise.all([
fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 }),
fetchGetMyOwnedProjectPage({ pageNo: 1, pageSize: -1 })
]);
if (!participated.error) {
participatedItems.value = buildWorkbenchParticipatedProjects(participated.data?.list ?? []);
}
if (!owned.error) {
ownedItems.value = buildWorkbenchOwnedProjects(owned.data?.list ?? []);
}
});
onMounted(refresh);
const currentOwnedId = ref<string>('');
watch(ownedItems, list => { watch(ownedItems, list => {
if (!list.find(item => item.id === currentOwnedId.value)) { if (!list.find(item => item.id === currentOwnedId.value)) {
currentOwnedId.value = list[0]?.id ?? ''; currentOwnedId.value = list[0]?.id ?? '';
@@ -36,6 +56,24 @@ watch(ownedItems, list => {
}); });
const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null); const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null);
// 成员负载:柱长按组内最高任务数归一(相对负载),颜色按绝对任务数分档(与团队负载 6/4 阈值一致)
function resolveMemberLoadLevel(activeTaskCount: number) {
if (activeTaskCount >= 6) return 'over';
if (activeTaskCount >= 4) return 'warn';
return 'ok';
}
const ownedMembersView = computed(() => {
const members = currentOwned.value?.members ?? [];
const maxTaskCount = members.reduce((max, member) => Math.max(max, member.activeTaskCount), 0);
return members.map(member => ({
userId: member.userId,
userName: member.userName,
activeTaskCount: member.activeTaskCount,
barPercent: maxTaskCount > 0 ? Math.round((member.activeTaskCount / maxTaskCount) * 100) : 0,
level: resolveMemberLoadLevel(member.activeTaskCount)
}));
});
function handleEnterProjectList() { function handleEnterProjectList() {
routerPushByKey('project_list'); routerPushByKey('project_list');
} }
@@ -43,12 +81,12 @@ function handleEnterProjectList() {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="我的项目" title="我的项目"
icon="mdi:briefcase-outline" icon="mdi:briefcase-outline"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="workbench-project__tabs"> <div class="workbench-project__tabs">
<ElRadioGroup v-model="activeView" size="small"> <ElRadioGroup v-model="activeView" size="small">
@@ -60,122 +98,125 @@ function handleEnterProjectList() {
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" /> <SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton> </ElButton>
</div> </div>
<div class="workbench-project__scroll">
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<!-- 我参与的网格视图 --> <div v-if="participatedItems.length" class="workbench-project__grid">
<template v-if="activeView === 'participated'"> <article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p> <div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<div v-if="participatedItems.length" class="workbench-project__grid"> <h4 class="workbench-project__card-title" :title="item.name">{{ item.name }}</h4>
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card"> <span v-if="item.code" class="workbench-project__card-code">{{ item.code }}</span>
<div class="workbench-project__card-header"> </div>
<div class="workbench-project__card-title-group"> <span
<h4 class="workbench-project__card-title">{{ item.name }}</h4> class="workbench-project__card-status"
<span class="workbench-project__card-code">{{ item.code }}</span> :class="`workbench-project__card-status--${item.statusTone}`"
>
{{ item.statusName || '进行中' }}
</span>
</div> </div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
</div>
<div class="workbench-project__card-role"> <div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span> <span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong> <strong class="workbench-project__card-role-value">{{ item.myRole || '—' }}</strong>
</div> </div>
<div class="workbench-project__progress"> <div class="workbench-project__progress">
<div class="workbench-project__progress-header"> <div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span> <span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong> <strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
</div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div> </div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div>
<div class="workbench-project__footer"> <div class="workbench-project__footer">
<div class="workbench-project__footer-block"> <div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span> <span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value"> <strong class="workbench-project__footer-value">
{{ item.myTaskCount }} {{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub"> <span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }} 待处理 {{ item.myPendingTaskCount }}
</span> </span>
</strong> </strong>
</div>
</div> </div>
<div class="workbench-project__footer-block workbench-project__footer-block--right"> </article>
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div>
</article>
</div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</template>
<!-- 我负责的单对象深度详情 -->
<template v-else>
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
<template v-else>
<div v-if="ownedItems.length > 1" class="ps-head">
<span class="ps-pin-label">当前项目</span>
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div> </div>
<div v-else class="ps-head ps-head--single"> <ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
<span class="ps-pin-label">当前项目</span>
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
<span>{{ currentOwned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ currentOwned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ currentOwned.remainingDays }} · 我的角色{{ currentOwned.myRole }}</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in currentOwned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members">
<li v-for="m in currentOwned.members" :key="m.name">
<span class="ps-member-name">{{ m.name }}</span>
<div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" />
</div>
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span>
</li>
</ul>
</template> </template>
</template>
<!-- 我负责的单对象深度详情 -->
<template v-else>
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
<template v-else>
<div v-if="ownedItems.length > 1" class="ps-head">
<span class="ps-pin-label">当前项目</span>
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div v-else class="ps-head ps-head--single">
<span class="ps-pin-label">当前项目</span>
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
<span>{{ currentOwned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ currentOwned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div v-if="currentOwned.plannedEndDate" class="ps-sub">
计划结束 {{ currentOwned.plannedEndDate }}
<template v-if="currentOwned.remainingDays !== null">
·
{{
currentOwned.remainingDays >= 0
? `${currentOwned.remainingDays}`
: `已逾期 ${-currentOwned.remainingDays}`
}}
</template>
</div>
<div v-else class="ps-sub">未设置计划结束日期</div>
<div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members">
<li v-for="m in ownedMembersView" :key="m.userId">
<span class="ps-member-name" :title="m.userName || ''">{{ m.userName || '—' }}</span>
<div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.barPercent}%` }" />
</div>
<span class="ps-member-tasks">{{ m.activeTaskCount }}</span>
</li>
</ul>
</template>
</template>
</div>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
@@ -186,6 +227,13 @@ function handleEnterProjectList() {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
flex-shrink: 0;
}
.workbench-project__scroll {
flex: 1;
min-height: 0;
overflow: auto;
} }
.workbench-project__desc { .workbench-project__desc {
@@ -202,7 +250,8 @@ function handleEnterProjectList() {
.workbench-project__grid { .workbench-project__grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); /* 按容器宽度自适应列数而非视口minmax 180 让 w7≈588px 容器排 3 列auto-fit 平分不留白 */
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; gap: 16px;
} }
@@ -239,6 +288,11 @@ function handleEnterProjectList() {
.workbench-project__card-title { .workbench-project__card-title {
margin: 0; margin: 0;
/* 标题最长等效 10 个汉字宽度10em≈160px超出省略号hover 看完整名 */
max-width: 10em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(15 23 42 / 98%); color: rgb(15 23 42 / 98%);
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
@@ -356,10 +410,6 @@ function handleEnterProjectList() {
gap: 4px; gap: 4px;
} }
.workbench-project__footer-block--right {
align-items: flex-end;
}
.workbench-project__footer-label { .workbench-project__footer-label {
color: rgb(100 116 139 / 92%); color: rgb(100 116 139 / 92%);
font-size: 12px; font-size: 12px;
@@ -377,18 +427,6 @@ function handleEnterProjectList() {
font-weight: 600; font-weight: 600;
} }
@media (width <= 1280px) {
.workbench-project__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-project__grid {
grid-template-columns: 1fr;
}
}
/* ===== 我负责的:单对象深度详情样式 ===== */ /* ===== 我负责的:单对象深度详情样式 ===== */
.ps-head { .ps-head {
display: flex; display: flex;
@@ -472,32 +510,11 @@ function handleEnterProjectList() {
font-weight: 600; font-weight: 600;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.ps-milestones,
.ps-members { .ps-members {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.ps-milestones li {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.ps-milestones li:last-child {
border-bottom: none;
}
.ps-time {
font-size: 12px;
font-weight: 600;
}
.ps-time.tone-amber {
color: var(--el-color-warning);
}
.ps-time.tone-slate {
color: var(--el-text-color-secondary);
}
.ps-members li { .ps-members li {
display: grid; display: grid;
grid-template-columns: 60px 1fr 30px; grid-template-columns: 60px 1fr 30px;
@@ -506,6 +523,12 @@ function handleEnterProjectList() {
padding: 4px 0; padding: 4px 0;
font-size: 12px; font-size: 12px;
} }
.ps-member-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
}
.ps-bar { .ps-bar {
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
@@ -514,6 +537,7 @@ function handleEnterProjectList() {
} }
.ps-bar-inner { .ps-bar-inner {
height: 100%; height: 100%;
transition: width 240ms ease;
} }
.ps-bar-inner.is-ok { .ps-bar-inner.is-ok {
background: var(--el-color-success); background: var(--el-color-success);
@@ -524,7 +548,7 @@ function handleEnterProjectList() {
.ps-bar-inner.is-over { .ps-bar-inner.is-over {
background: var(--el-color-danger); background: var(--el-color-danger);
} }
.ps-member-load { .ps-member-tasks {
text-align: right; text-align: right;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 11px; font-size: 11px;

View File

@@ -2,15 +2,17 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { buildWorkbenchProjectHealthCards } from '../homepage'; import { buildWorkbenchProjectHealthCards } from '../homepage';
import { workbenchProjectHealthMock } from '../mock'; import { workbenchProjectHealthMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock)); const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
@@ -32,13 +34,13 @@ const productCards: ProductHealth[] = [
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="产品 / 项目健康度" title="产品 / 项目健康度"
icon="mdi:heart-pulse" icon="mdi:heart-pulse"
:badge-count="projectCards.length + productCards.length" :badge-count="projectCards.length + productCards.length"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="demo-banner"> <div class="demo-banner">
<SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" /> <SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" />

View File

@@ -78,6 +78,7 @@ function handleConfirm() {
direction="rtl" direction="rtl"
size="380px" size="380px"
title="选择快捷入口菜单" title="选择快捷入口菜单"
append-to-body
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<template #default> <template #default>

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