13 Commits

Author SHA1 Message Date
17690283f6 Merge remote-tracking branch 'origin/main' 2026-06-14 09:11:33 +08:00
dk
030dc737fc feat(工作报告定时生成): 工作报告现在可以定时生成,并且可以刷新当前报告。 2026-06-13 22:13:44 +08:00
609a01dc8a refactor(projects): 消息提示增加等级区分 2026-06-13 14:59:31 +08:00
dk
80f028bcb9 fix(工作报告): 修复工作报告存在的若干问题。
feat(加班申请): 支持批量审批。
2026-06-13 13:06:39 +08:00
5061eced32 refactor(projects): 登录页面重新设计 2026-06-12 22:42:23 +08:00
6896a86130 feat(projects): 工作台接口切换为真实数据 2026-06-12 19:49:17 +08:00
0652a24c5e feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发 2026-06-11 14:02:26 +08:00
dk
d53a8dfae5 fix(加班申请): 去掉撤销相关的状态和动作。
feat(工作报告): 开发工作报告功能
2026-06-11 10:56:24 +08:00
2e369b23a9 refactor(projects): 删除废弃代码 2026-06-05 16:29:35 +08:00
b72ad00912 fix(error-message): 删除用户可见错误文案规范HTML文档
- 移除了完整的用户可见错误文案规范HTML文件
2026-06-04 21:07:44 +08:00
7cc29e0a35 fix(projects): 针对技术负债去优化代码 2026-06-04 21:06:05 +08:00
39458386ae feat(projects): 工作台部分组件调成真实数据 2026-06-04 11:26:51 +08:00
dk
acef4418d8 fix(加班申请): 使用后端专门返回状态的接口,代替使用字典。
fix(status-tag.ts):把产品需求、项目需求的状态颜色定义收敛到此处。
2026-06-04 10:49:34 +08:00
188 changed files with 20744 additions and 15073 deletions

4
.env
View File

@@ -2,9 +2,9 @@
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin" # 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
VITE_BASE_URL=/ VITE_BASE_URL=/
VITE_APP_TITLE=研发内部管理系统 VITE_APP_TITLE=研发管理系统
VITE_APP_DESC=Frontend application for 灿能研发内部管理系统 VITE_APP_DESC=Frontend application for 灿能研发管理系统
# 图标名称前缀 # 图标名称前缀
VITE_ICON_PREFIX=icon VITE_ICON_PREFIX=icon

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

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

View File

@@ -131,16 +131,23 @@ export function setupElegantRouter() {
order: 1, order: 1,
keepAlive: true keepAlive: true
}, },
'personal-center_my-weekly': { 'personal-center_work-report': {
icon: 'mdi:calendar-week-outline', icon: 'mdi:file-chart-outline',
order: 2,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
order: 3, order: 3,
keepAlive: true keepAlive: true
}, },
'personal-center_work-report_weekly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_monthly': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_work-report_project': {
hideInMenu: true,
activeMenu: 'personal-center_work-report'
},
'personal-center_my-performance': { 'personal-center_my-performance': {
icon: 'mdi:trophy-outline', icon: 'mdi:trophy-outline',
order: 4, order: 4,

View File

@@ -1,12 +1,12 @@
{ {
"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",
"pageComponentPattern": "view.<routeName>", "pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>" "singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
}, },
"total": 23, "total": 22,
"items": [ "items": [
{ {
"name": "product_list", "name": "product_list",
@@ -306,15 +306,15 @@
"source": "generated" "source": "generated"
}, },
{ {
"name": "personal-center_my-weekly", "name": "personal-center_work-report",
"path": "/personal-center/my-weekly", "path": "/personal-center/work-report",
"component": "view.personal-center_my-weekly", "component": "view.personal-center_work-report",
"title": "我的周报", "title": "工作报告",
"routeTitle": "personal-center_my-weekly", "routeTitle": "personal-center_work-report",
"i18nKey": "route.personal-center_my-weekly", "i18nKey": "route.personal-center_work-report",
"icon": "mdi:calendar-week-outline", "icon": "mdi:file-chart-outline",
"localIcon": null, "localIcon": null,
"order": 1, "order": 3,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -323,44 +323,11 @@
"redirect": null, "redirect": null,
"props": null, "props": null,
"meta": { "meta": {
"title": "我的周报", "title": "工作报告",
"i18nKey": "route.personal-center_my-weekly", "i18nKey": "route.personal-center_work-report",
"icon": "mdi:calendar-week-outline", "icon": "mdi:file-chart-outline",
"localIcon": null, "localIcon": null,
"order": 1, "order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-monthly",
"path": "/personal-center/my-monthly",
"component": "view.personal-center_my-monthly",
"title": "我的月报",
"routeTitle": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的月报",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,
@@ -380,7 +347,7 @@
"i18nKey": "route.personal-center_my-performance", "i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline", "icon": "mdi:trophy-outline",
"localIcon": null, "localIcon": null,
"order": 3, "order": 4,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -393,7 +360,7 @@
"i18nKey": "route.personal-center_my-performance", "i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline", "icon": "mdi:trophy-outline",
"localIcon": null, "localIcon": null,
"order": 3, "order": 4,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,
@@ -413,7 +380,7 @@
"i18nKey": "route.personal-center_my-application", "i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline", "icon": "mdi:file-document-outline",
"localIcon": null, "localIcon": null,
"order": 4, "order": 5,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -426,7 +393,7 @@
"i18nKey": "route.personal-center_my-application", "i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline", "icon": "mdi:file-document-outline",
"localIcon": null, "localIcon": null,
"order": 4, "order": 5,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,
@@ -446,7 +413,7 @@
"i18nKey": "route.personal-center_pending-approval", "i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline", "icon": "mdi:check-decagram-outline",
"localIcon": null, "localIcon": null,
"order": 5, "order": 7,
"hideInMenu": false, "hideInMenu": false,
"keepAlive": true, "keepAlive": true,
"activeMenu": null, "activeMenu": null,
@@ -459,7 +426,7 @@
"i18nKey": "route.personal-center_pending-approval", "i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline", "icon": "mdi:check-decagram-outline",
"localIcon": null, "localIcon": null,
"order": 5, "order": 7,
"keepAlive": true, "keepAlive": true,
"hideInMenu": false, "hideInMenu": false,
"activeMenu": null, "activeMenu": null,

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",

2458
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,12 @@ export interface SearchField {
label: string; label: string;
/** 字段类型 */ /** 字段类型 */
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict'; type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
/** date 字段的日期粒度 */
dateType?: 'date' | 'month';
/** dateRange 字段的日期范围粒度 */
dateRangeType?: 'daterange' | 'monthrange';
/** 日期字段提交格式 */
valueFormat?: string;
/** 占位列数,默认 1 */ /** 占位列数,默认 1 */
span?: number; span?: number;
/** select 类型的选项 */ /** select 类型的选项 */
@@ -156,23 +162,23 @@ function handleSearch() {
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
start-placeholder="开始日期" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
end-placeholder="结束日期" :end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<DictSelect <DictSelect
@@ -253,23 +259,23 @@ function handleSearch() {
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="date" :type="field.dateType || 'date'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<ElDatePicker <ElDatePicker
v-else-if="field.type === 'dateRange'" v-else-if="field.type === 'dateRange'"
:model-value="props.modelValue[field.key]" :model-value="props.modelValue[field.key]"
type="daterange" :type="field.dateRangeType || 'daterange'"
:placeholder="field.placeholder" :placeholder="field.placeholder"
clearable clearable
:disabled="props.disabled" :disabled="props.disabled"
value-format="YYYY-MM-DD" :value-format="field.valueFormat || 'YYYY-MM-DD'"
start-placeholder="开始日期" :start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
end-placeholder="结束日期" :end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
<DictSelect <DictSelect

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';
/** /**
* 加班时长快捷选项字典编码 * 加班时长快捷选项字典编码
* *
@@ -127,3 +119,12 @@ export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_applica
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration * 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/ */
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration'; export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
/**
* 站内信消息等级字典编码
*
* 对应业务字段:站内信 NotifyMessage.level1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level
* 显示名与颜色hex均走字典前端按 level 取色不硬编码。
*/
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';

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,8 +14,10 @@ export type StatusDomain =
| 'taskAssigneeMember' | 'taskAssigneeMember'
| 'project' | 'project'
| 'product' | 'product'
| 'requirement' | 'productRequirement'
| 'projectRequirement'
| 'workOrder' | 'workOrder'
| 'workReport'
| 'personalItem' | 'personalItem'
| 'overtimeApplication'; | 'overtimeApplication';
@@ -52,10 +54,40 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
project: {}, project: {},
// 产品(待补全) // 产品(待补全)
product: {}, product: {},
// 需求(待补全) // 产品需求
requirement: {}, productRequirement: {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 项目需求
projectRequirement: {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 工单(待补全) // 工单(待补全)
workOrder: {}, workOrder: {},
// 工作报告
workReport: {
draft: 'info',
pending_approval: 'warning',
approved: 'success',
rejected: 'danger'
},
// 个人事项 // 个人事项
personalItem: { personalItem: {
pending: 'info', pending: 'info',
@@ -67,8 +99,7 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
overtimeApplication: { overtimeApplication: {
pending: 'warning', pending: 'warning',
approved: 'success', approved: 'success',
rejected: 'danger', rejected: 'danger'
cancelled: 'info'
} }
}; };
@@ -83,7 +114,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

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

View File

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

View File

@@ -1,76 +1,133 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useInfiniteScroll } from '@vueuse/core'; import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { useDictStore } from '@/store/modules/dict';
import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' }); defineOptions({ name: 'NotificationBell' });
interface NotificationItem { const dictStore = useDictStore();
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
// 通知 mock扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移 type TabKey = 'unread' | 'read';
function buildMockNotifications(): NotificationItem[] {
const titles = [ interface MessageListState {
'你被指派为执行「迭代 24.06」负责人', items: Api.NotifyMessage.NotifyMessage[];
'任务「SSO 改造」状态变更:开发中 → 待验收', pageNo: number;
'需求「多币种支持」评审通过', total: number;
'工单 #1042 已分派给你', loading: boolean;
'需求「订单导出」被退回,请补充材料', /** 是否已按当前关键字拉过第一页tab 懒加载 / 失效重拉用) */
'@ 你的评论已被回复', loaded: boolean;
'项目「客户中心 2.0」周报已生成', /** 竞态令牌:重置后递增,过期响应直接丢弃 */
'工单 #1098 客户回复待处理', token: number;
'执行「迭代 24.05」已结束',
'需求「批量审批」分配给你'
];
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
return Array.from({ length: 60 }, (_, i) => ({
id: `m${i + 1}`,
title: `${titles[i % titles.length]}#${i + 1}`,
timeLabel: times[Math.floor(i / 6) % times.length],
unread: i < 14
}));
} }
const notifications = ref<NotificationItem[]>(buildMockNotifications()); function createListState(): MessageListState {
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
}
const unreadAll = computed(() => notifications.value.filter(n => n.unread)); const listStates = reactive<Record<TabKey, MessageListState>>({
const readAll = computed(() => notifications.value.filter(n => !n.unread)); unread: createListState(),
const unreadCount = computed(() => unreadAll.value.length); read: createListState()
});
const unreadCount = ref(0);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value))); const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false); const drawerOpen = ref(false);
const activeTab = ref<'unread' | 'read'>('unread'); const activeTab = ref<TabKey>('unread');
const searchKeyword = ref(''); const searchKeyword = ref('');
function matchesKeyword(item: NotificationItem) { const detailVisible = ref(false);
const kw = searchKeyword.value.trim(); const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
if (!kw) return true;
return item.title.toLowerCase().includes(kw.toLowerCase()); function keywordParam() {
return searchKeyword.value.trim() || undefined;
} }
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword)); /** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined由 CSS 兜底 */
const filteredRead = computed(() => readAll.value.filter(matchesKeyword)); function levelDotColor(level: number) {
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
}
const unreadPageSize = ref(PAGE_SIZE); async function refreshUnreadCount() {
const readPageSize = ref(PAGE_SIZE); const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
unreadCount.value = data;
}
}
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value)); function resetList(tab: TabKey) {
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value)); const state = listStates[tab];
state.token += 1;
state.items = [];
state.pageNo = 1;
state.total = 0;
state.loading = false;
state.loaded = false;
}
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length); async function loadPage(tab: TabKey) {
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length); const state = listStates[tab];
if (state.loading) return;
const token = state.token;
state.loading = true;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
});
if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => { watch(searchKeyword, () => {
unreadPageSize.value = PAGE_SIZE; applyKeywordSearch();
readPageSize.value = PAGE_SIZE;
}); });
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然 watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null; type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null); const unreadScrollbar = ref<ScrollbarRefValue>(null);
@@ -79,7 +136,9 @@ const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll( useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef, () => unreadScrollbar.value?.wrapRef,
() => { () => {
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE; if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
loadPage('unread');
}
}, },
{ distance: 48 } { distance: 48 }
); );
@@ -87,43 +146,90 @@ useInfiniteScroll(
useInfiniteScroll( useInfiniteScroll(
() => readScrollbar.value?.wrapRef, () => readScrollbar.value?.wrapRef,
() => { () => {
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE; if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
loadPage('read');
}
}, },
{ distance: 48 } { distance: 48 }
); );
function openDrawer() { function openDrawer() {
drawerOpen.value = true; drawerOpen.value = true;
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
resetList('unread');
resetList('read');
loadPage(activeTab.value);
refreshUnreadCount();
} }
function closeDrawer() { function closeDrawer() {
drawerOpen.value = false; drawerOpen.value = false;
} }
function markRead(item: NotificationItem) {
if (!item.unread) return;
item.unread = false;
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', item.id);
}
function markAllRead() {
notifications.value.forEach(item => {
item.unread = false;
});
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
function openItem(item: NotificationItem) {
markRead(item);
// eslint-disable-next-line no-console
console.warn('[notification] open', item.id);
}
function onDrawerClosed() { function onDrawerClosed() {
searchKeyword.value = ''; searchKeyword.value = '';
} }
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
if (error) return;
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
const state = listStates.unread;
const index = state.items.findIndex(row => row.id === item.id);
if (index >= 0) {
state.items.splice(index, 1);
state.total = Math.max(0, state.total - 1);
}
unreadCount.value = Math.max(0, unreadCount.value - 1);
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
resetList('read');
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
loadPage('unread');
}
}
function openDetail(row: Api.NotifyMessage.NotifyMessage) {
// 弹框持有该行引用,正文不随未读列表移除而消失
detailMessage.value = row;
detailVisible.value = true;
// 未读消息「打开即已读」:后台静默标记,避免"看一半就跑到已读"
if (!row.readStatus) {
markRead(row);
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
// 等级徽标颜色/文案走字典:若未在登录缓存内则按编码补拉一次(已缓存时不发请求)
dictStore.ensureDictData(NOTIFY_MESSAGE_LEVEL_DICT_CODE);
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
refreshUnreadCount();
}, UNREAD_COUNT_POLL_INTERVAL);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script> </script>
<template> <template>
@@ -137,21 +243,18 @@ function onDrawerClosed() {
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span> <span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button> </button>
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed"> <ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
<div class="notification-bell__panel"> <template #header>
<header class="notification-bell__header"> <div class="notification-bell__header-main">
<span class="notification-bell__title"> <span class="notification-bell__title">
通知 通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span> <span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span> </span>
<span class="notification-bell__header-actions"> <ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton> </div>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer"> </template>
<SvgIcon icon="mdi:close" />
</button>
</span>
</header>
<div class="notification-bell__panel">
<div class="notification-bell__search"> <div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable> <ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix> <template #prefix>
@@ -165,61 +268,95 @@ function onDrawerClosed() {
<template #label> <template #label>
<span class="notification-bell__tab-label"> <span class="notification-bell__tab-label">
未读 未读
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span> <span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
</span> </span>
</template> </template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll"> <ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="visibleUnread.length > 0" class="notification-bell__list"> <ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
<li <li
v-for="row in visibleUnread" v-for="row in listStates.unread.items"
:key="row.id" :key="row.id"
class="notification-bell__row is-unread" class="notification-bell__row is-unread"
@click="openItem(row)" @click="openDetail(row)"
> >
<span class="notification-bell__row-dot" /> <span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body"> <div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div> <div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div> <div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div> </div>
</li> </li>
</ul> </ul>
<div v-else class="notification-bell__empty"> <div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }} {{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div> </div>
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint"> <div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }} {{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div> </div>
</ElScrollbar> </ElScrollbar>
</ElTabPane> </ElTabPane>
<ElTabPane name="read"> <ElTabPane name="read">
<template #label> <template #label>
<span class="notification-bell__tab-label"> <span class="notification-bell__tab-label">已读</span>
已读
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
</span>
</template> </template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll"> <ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="visibleRead.length > 0" class="notification-bell__list"> <ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)"> <li
<span class="notification-bell__row-dot" /> v-for="row in listStates.read.items"
:key="row.id"
class="notification-bell__row"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body"> <div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div> <div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div> <div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div> </div>
</li> </li>
</ul> </ul>
<div v-else class="notification-bell__empty"> <div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }} {{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div> </div>
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint"> <div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }} {{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div> </div>
</ElScrollbar> </ElScrollbar>
</ElTabPane> </ElTabPane>
</ElTabs> </ElTabs>
</div> </div>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer> </ElDrawer>
<ElDialog v-model="detailVisible" width="520px" align-center class="notification-bell__detail">
<template #header>
<div class="notification-bell__detail-head">
<span class="notification-bell__detail-sender">{{ detailMessage?.templateNickname || '系统通知' }}</span>
<DictTag
v-if="detailMessage"
:dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE"
:value="detailMessage.level"
size="small"
round
/>
</div>
</template>
<div v-if="detailMessage" class="notification-bell__detail-body">
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
</div>
<template #footer>
<ElButton @click="detailVisible = false">关闭</ElButton>
</template>
</ElDialog>
</template> </template>
<style scoped> <style scoped>
@@ -258,18 +395,53 @@ function onDrawerClosed() {
.notification-bell__badge { .notification-bell__badge {
position: absolute; position: absolute;
top: 4px; top: 2px;
right: 4px; right: 2px;
min-width: 16px; min-width: 18px;
height: 16px; height: 18px;
padding: 0 4px; padding: 0 5px;
border: 1px solid #fff;
border-radius: 999px; border-radius: 999px;
background-color: var(--el-color-danger); background-color: var(--el-color-danger);
color: #fff; color: #fff;
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 700;
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
animation: notification-badge-pulse 1.6s ease-in-out infinite;
}
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
.notification-bell__badge::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 999px;
background-color: var(--el-color-danger);
animation: notification-badge-ping 1.6s ease-out infinite;
z-index: -1;
}
@keyframes notification-badge-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
}
@keyframes notification-badge-ping {
0% {
transform: scale(1);
opacity: 0.6;
}
70%,
100% {
transform: scale(1.9);
opacity: 0;
}
} }
.notification-bell__panel { .notification-bell__panel {
@@ -278,13 +450,14 @@ function onDrawerClosed() {
height: 100%; height: 100%;
} }
.notification-bell__header { .notification-bell__header-main {
display: flex; display: flex;
flex: 1;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding-bottom: 12px; min-width: 0;
border-bottom: 1px solid var(--el-border-color-lighter); margin-right: 8px;
} }
.notification-bell__title { .notification-bell__title {
@@ -305,37 +478,8 @@ function onDrawerClosed() {
font-weight: 600; font-weight: 600;
} }
.notification-bell__header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.notification-bell__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
transition:
background-color 120ms ease,
color 120ms ease;
}
.notification-bell__close:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.notification-bell__search { .notification-bell__search {
padding: 12px 0 4px; padding: 0 0 4px;
} }
.notification-bell__tabs { .notification-bell__tabs {
@@ -393,8 +537,8 @@ function onDrawerClosed() {
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 12px 4px; padding: 12px 4px;
cursor: pointer;
border-radius: 8px; border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease; transition: background-color 120ms ease;
} }
@@ -434,8 +578,14 @@ function onDrawerClosed() {
font-weight: 500; font-weight: 500;
} }
.notification-bell__row-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.notification-bell__row-time { .notification-bell__row-time {
margin-top: 4px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 12px; font-size: 12px;
} }
@@ -454,4 +604,41 @@ function onDrawerClosed() {
font-size: 12px; font-size: 12px;
user-select: none; user-select: none;
} }
.notification-bell__detail-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.notification-bell__detail-head {
display: flex;
align-items: center;
gap: 10px;
padding-right: 8px;
min-width: 0;
}
.notification-bell__detail-sender {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-bell__detail-content {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.notification-bell__detail-time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style> </style>

View File

@@ -41,11 +41,6 @@ const { isFullscreen, toggle } = useFullscreen();
<div> <div>
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" /> <FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
</div> </div>
<ThemeSchemaSwitch
:theme-schema="themeStore.themeScheme"
:is-dark="themeStore.darkMode"
@switch="themeStore.toggleThemeScheme"
/>
<div> <div>
<ThemeButton /> <ThemeButton />
</div> </div>

View File

@@ -12,7 +12,7 @@ const { selectedKeyDummy, handleSelect } = useMenu();
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu <ElMenu
ellipsis ellipsis
class="w-full" class="w-full"

View File

@@ -93,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <!-- deferBaseLayout 二次挂载时 GlobalMenu 已缓存为同步挂载目标 div 还未插入 document不延迟解析会静默失败且不重试 -->
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<div class="mix-header-nav size-full min-w-0 flex-y-center"> <div class="mix-header-nav size-full min-w-0 flex-y-center">
<button <button
v-if="activeFirstLevelMenu" v-if="activeFirstLevelMenu"
@@ -161,7 +162,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
</div> </div>
</div> </div>
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey" :active-menu-key="activeFirstLevelMenuKey"

View File

@@ -55,7 +55,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu <ElMenu
ellipsis ellipsis
class="w-full" class="w-full"
@@ -66,7 +66,7 @@ watch(
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" /> <MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu> </ElMenu>
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar> <SimpleScrollbar>
<ElMenu <ElMenu
mode="vertical" mode="vertical"

View File

@@ -38,7 +38,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar> <SimpleScrollbar>
<ElMenu <ElMenu
mode="vertical" mode="vertical"

View File

@@ -90,7 +90,7 @@ watch(
</script> </script>
<template> <template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu"> <div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu <FirstLevelMenu
:menus="allMenus" :menus="allMenus"

View File

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

View File

@@ -169,8 +169,10 @@ const local: App.I18n.Schema = {
'personal-center': 'Personal Center', 'personal-center': 'Personal Center',
'personal-center_my-profile': 'My Profile', 'personal-center_my-profile': 'My Profile',
'personal-center_my-item': 'My Items', 'personal-center_my-item': 'My Items',
'personal-center_my-weekly': 'My Weekly Report', 'personal-center_work-report': 'Work Report',
'personal-center_my-monthly': 'My Monthly Report', 'personal-center_work-report_weekly': 'Weekly Report',
'personal-center_work-report_monthly': 'Monthly Report',
'personal-center_work-report_project': 'Project Fortnightly Report',
'personal-center_my-performance': 'My Performance', 'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application', 'personal-center_my-application': 'My Application',
'personal-center_overtime-application': 'Overtime Application', 'personal-center_overtime-application': 'Overtime Application',
@@ -178,16 +180,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 +203,7 @@ const local: App.I18n.Schema = {
exception: 'Exception', exception: 'Exception',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: 'Plugin',
plugin_copy: 'Copy',
plugin_charts: 'Charts',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: 'Icon',
plugin_map: 'Map',
plugin_print: 'Print',
plugin_swiper: 'Swiper',
plugin_video: 'Video',
plugin_barcode: 'Barcode',
plugin_pinyin: 'pinyin',
plugin_excel: 'Excel',
plugin_pdf: 'PDF preview',
plugin_gantt: 'Gantt Chart',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: 'Typeit',
plugin_tables: 'Tables',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -328,45 +299,6 @@ const local: App.I18n.Schema = {
}, },
creativity: 'Creativity' creativity: 'Creativity'
}, },
function: {
tab: {
tabOperate: {
title: 'Tab Operation',
addTab: 'Add Tab',
addTabDesc: 'To user management page',
closeTab: 'Close Tab',
closeCurrentTab: 'Close Current Tab',
closeAboutTab: 'Close "User Management" Tab',
addMultiTab: 'Add Multi Tab',
addMultiTabDesc1: 'To MultiTab page',
addMultiTabDesc2: 'To MultiTab page(with query params)'
},
tabTitle: {
title: 'Tab Title',
changeTitle: 'Change Title',
change: 'Change',
resetTitle: 'Reset Title',
reset: 'Reset'
}
},
multiTab: {
routeParam: 'Route Param',
backTab: 'Back function_tab'
},
toggleAuth: {
toggleAccount: 'Toggle Account',
authHook: 'Auth Hook Function `hasAuth`',
superAdminVisible: 'Super Admin Visible',
adminVisible: 'Admin Visible',
adminOrUserVisible: 'Admin and User Visible'
},
request: {
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
repeatedError: 'Repeated Request Error',
repeatedErrorMsg1: 'Custom Request Error 1',
repeatedErrorMsg2: 'Custom Request Error 2'
}
},
system: { system: {
common: { common: {
status: { status: {

View File

@@ -1,6 +1,6 @@
const local: App.I18n.Schema = { const local: App.I18n.Schema = {
system: { system: {
title: '研发内部管理系统' title: '研发管理系统'
}, },
common: { common: {
action: '操作', action: '操作',
@@ -169,8 +169,10 @@ const local: App.I18n.Schema = {
'personal-center': '个人中心', 'personal-center': '个人中心',
'personal-center_my-profile': '个人信息', 'personal-center_my-profile': '个人信息',
'personal-center_my-item': '我的事项', 'personal-center_my-item': '我的事项',
'personal-center_my-weekly': '我的周报', 'personal-center_work-report': '工作报告',
'personal-center_my-monthly': '我的月报', 'personal-center_work-report_weekly': '个人周报',
'personal-center_work-report_monthly': '个人月报',
'personal-center_work-report_project': '项目半月报',
'personal-center_my-performance': '我的绩效', 'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请', 'personal-center_my-application': '我的申请',
'personal-center_overtime-application': '加班申请', 'personal-center_overtime-application': '加班申请',
@@ -178,16 +180,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 +203,7 @@ const local: App.I18n.Schema = {
exception: '异常页', exception: '异常页',
exception_403: '403', exception_403: '403',
exception_404: '404', exception_404: '404',
exception_500: '500', exception_500: '500'
plugin: '插件示例',
plugin_copy: '剪贴板',
plugin_charts: '图表',
plugin_charts_echarts: 'ECharts',
plugin_charts_antv: 'AntV',
plugin_charts_vchart: 'VChart',
plugin_icon: '图标',
plugin_map: '地图',
plugin_print: '打印',
plugin_swiper: 'Swiper',
plugin_video: '视频',
plugin_barcode: '条形码',
plugin_pinyin: '拼音',
plugin_excel: 'Excel',
plugin_pdf: 'PDF 预览',
plugin_gantt: '甘特图',
plugin_gantt_dhtmlx: 'dhtmlxGantt',
plugin_gantt_vtable: 'VTableGantt',
plugin_typeit: '打字机',
plugin_tables: '表格',
plugin_tables_vtable: 'VTable'
}, },
page: { page: {
login: { login: {
@@ -284,7 +255,7 @@ const local: App.I18n.Schema = {
about: { about: {
title: '关于', title: '关于',
introduction: introduction:
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。', '灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
projectInfo: { projectInfo: {
title: '项目信息', title: '项目信息',
version: '版本', version: '版本',
@@ -327,45 +298,6 @@ const local: App.I18n.Schema = {
}, },
creativity: '创意' creativity: '创意'
}, },
function: {
tab: {
tabOperate: {
title: '标签页操作',
addTab: '添加标签页',
addTabDesc: '跳转到用户管理页面',
closeTab: '关闭标签页',
closeCurrentTab: '关闭当前标签页',
closeAboutTab: '关闭"用户管理"标签页',
addMultiTab: '添加多标签页',
addMultiTabDesc1: '跳转到多标签页页面',
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
},
tabTitle: {
title: '标签页标题',
changeTitle: '修改标题',
change: '修改',
resetTitle: '重置标题',
reset: '重置'
}
},
multiTab: {
routeParam: '路由参数',
backTab: '返回 function_tab'
},
toggleAuth: {
toggleAccount: '切换账号',
authHook: '权限钩子函数 `hasAuth`',
superAdminVisible: '超级管理员可见',
adminVisible: '管理员可见',
adminOrUserVisible: '管理员和用户可见'
},
request: {
repeatedErrorOccurOnce: '重复请求错误只出现一次',
repeatedError: '重复请求错误',
repeatedErrorMsg1: '自定义请求错误 1',
repeatedErrorMsg2: '自定义请求错误 2'
}
},
system: { system: {
common: { common: {
status: { status: {

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"),
@@ -35,29 +27,14 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"), metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"), "personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"), "personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"), "personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"), "personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"), "personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"), "personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"), "personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"), "personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"), "personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"), "personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
plugin_icon: () => import("@/views/plugin/icon/index.vue"),
plugin_map: () => import("@/views/plugin/map/index.vue"),
plugin_pdf: () => import("@/views/plugin/pdf/index.vue"),
plugin_pinyin: () => import("@/views/plugin/pinyin/index.vue"),
plugin_print: () => import("@/views/plugin/print/index.vue"),
plugin_swiper: () => import("@/views/plugin/swiper/index.vue"),
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
plugin_video: () => import("@/views/plugin/video/index.vue"),
product_dashboard: () => import("@/views/product/dashboard/index.vue"), product_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"), product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"), product_requirement: () => import("@/views/product/requirement/index.vue"),

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',
@@ -303,18 +185,6 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'personal-center_my-monthly',
path: '/personal-center/my-monthly',
component: 'view.personal-center_my-monthly',
meta: {
title: 'personal-center_my-monthly',
i18nKey: 'route.personal-center_my-monthly',
icon: 'mdi:calendar-month-outline',
order: 2,
keepAlive: true
}
},
{ {
name: 'personal-center_my-performance', name: 'personal-center_my-performance',
path: '/personal-center/my-performance', path: '/personal-center/my-performance',
@@ -339,18 +209,6 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'personal-center_my-weekly',
path: '/personal-center/my-weekly',
component: 'view.personal-center_my-weekly',
meta: {
title: 'personal-center_my-weekly',
i18nKey: 'route.personal-center_my-weekly',
icon: 'mdi:calendar-week-outline',
order: 1,
keepAlive: true
}
},
{ {
name: 'personal-center_overtime-application', name: 'personal-center_overtime-application',
path: '/personal-center/overtime-application', path: '/personal-center/overtime-application',
@@ -374,223 +232,53 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 7, order: 7,
keepAlive: true keepAlive: true
} }
}
]
},
{
name: 'plugin',
path: '/plugin',
component: 'layout.base',
meta: {
title: '插件示例',
i18nKey: 'route.plugin',
order: 7,
icon: 'clarity:plugin-line'
},
children: [
{
name: 'plugin_barcode',
path: '/plugin/barcode',
component: 'view.plugin_barcode',
meta: {
title: 'plugin_barcode',
i18nKey: 'route.plugin_barcode',
icon: 'ic:round-barcode'
}
}, },
{ {
name: 'plugin_charts', name: 'personal-center_work-report',
path: '/plugin/charts', path: '/personal-center/work-report',
component: 'view.personal-center_work-report',
meta: { meta: {
title: 'plugin_charts', title: 'personal-center_work-report',
i18nKey: 'route.plugin_charts', i18nKey: 'route.personal-center_work-report',
icon: 'mdi:chart-areaspline' icon: 'mdi:file-chart-outline',
}, order: 3,
children: [
{
name: 'plugin_charts_antv',
path: '/plugin/charts/antv',
component: 'view.plugin_charts_antv',
meta: {
title: 'plugin_charts_antv',
i18nKey: 'route.plugin_charts_antv',
icon: 'hugeicons:flow-square'
}
},
{
name: 'plugin_charts_echarts',
path: '/plugin/charts/echarts',
component: 'view.plugin_charts_echarts',
meta: {
title: 'plugin_charts_echarts',
i18nKey: 'route.plugin_charts_echarts',
icon: 'simple-icons:apacheecharts'
}
},
{
name: 'plugin_charts_vchart',
path: '/plugin/charts/vchart',
component: 'view.plugin_charts_vchart',
meta: {
title: 'plugin_charts_vchart',
i18nKey: 'route.plugin_charts_vchart',
localIcon: 'visactor'
}
}
]
},
{
name: 'plugin_copy',
path: '/plugin/copy',
component: 'view.plugin_copy',
meta: {
title: 'plugin_copy',
i18nKey: 'route.plugin_copy',
icon: 'mdi:clipboard-outline'
}
},
{
name: 'plugin_excel',
path: '/plugin/excel',
component: 'view.plugin_excel',
meta: {
title: 'plugin_excel',
i18nKey: 'route.plugin_excel',
icon: 'ri:file-excel-2-line',
keepAlive: true keepAlive: true
}
},
{
name: 'plugin_gantt',
path: '/plugin/gantt',
meta: {
title: 'plugin_gantt',
i18nKey: 'route.plugin_gantt',
icon: 'ant-design:bar-chart-outlined'
}, },
children: [ children: [
{ {
name: 'plugin_gantt_dhtmlx', name: 'personal-center_work-report_monthly',
path: '/plugin/gantt/dhtmlx', path: '/personal-center/work-report/monthly',
component: 'view.plugin_gantt_dhtmlx', component: 'view.personal-center_work-report_monthly',
meta: { meta: {
title: 'plugin_gantt_dhtmlx', title: 'personal-center_work-report_monthly',
i18nKey: 'route.plugin_gantt_dhtmlx', i18nKey: 'route.personal-center_work-report_monthly',
icon: 'gridicons:posts' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
}, },
{ {
name: 'plugin_gantt_vtable', name: 'personal-center_work-report_project',
path: '/plugin/gantt/vtable', path: '/personal-center/work-report/project',
component: 'view.plugin_gantt_vtable', component: 'view.personal-center_work-report_project',
meta: { meta: {
title: 'plugin_gantt_vtable', title: 'personal-center_work-report_project',
i18nKey: 'route.plugin_gantt_vtable', i18nKey: 'route.personal-center_work-report_project',
localIcon: 'visactor' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} },
]
},
{
name: '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', name: 'personal-center_work-report_weekly',
path: '/plugin/tables/vtable', path: '/personal-center/work-report/weekly',
component: 'view.plugin_tables_vtable', component: 'view.personal-center_work-report_weekly',
meta: { meta: {
title: 'plugin_tables_vtable', title: 'personal-center_work-report_weekly',
i18nKey: 'route.plugin_tables_vtable', i18nKey: 'route.personal-center_work-report_weekly',
localIcon: 'visactor' hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} }
] ]
},
{
name: 'plugin_typeit',
path: '/plugin/typeit',
component: 'view.plugin_typeit',
meta: {
title: 'plugin_typeit',
i18nKey: 'route.plugin_typeit',
icon: 'mdi:typewriter'
}
},
{
name: 'plugin_video',
path: '/plugin/video',
component: 'view.plugin_video',
meta: {
title: 'plugin_video',
i18nKey: 'route.plugin_video',
icon: 'mdi:video'
}
} }
] ]
}, },

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",
@@ -192,33 +182,14 @@ const routeMap: RouteMap = {
"personal-center": "/personal-center", "personal-center": "/personal-center",
"personal-center_my-application": "/personal-center/my-application", "personal-center_my-application": "/personal-center/my-application",
"personal-center_my-item": "/personal-center/my-item", "personal-center_my-item": "/personal-center/my-item",
"personal-center_my-monthly": "/personal-center/my-monthly",
"personal-center_my-performance": "/personal-center/my-performance", "personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile", "personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_overtime-application": "/personal-center/overtime-application", "personal-center_overtime-application": "/personal-center/overtime-application",
"personal-center_pending-approval": "/personal-center/pending-approval", "personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin", "personal-center_work-report": "/personal-center/work-report",
"plugin_barcode": "/plugin/barcode", "personal-center_work-report_monthly": "/personal-center/work-report/monthly",
"plugin_charts": "/plugin/charts", "personal-center_work-report_project": "/personal-center/work-report/project",
"plugin_charts_antv": "/plugin/charts/antv", "personal-center_work-report_weekly": "/personal-center/work-report/weekly",
"plugin_charts_echarts": "/plugin/charts/echarts",
"plugin_charts_vchart": "/plugin/charts/vchart",
"plugin_copy": "/plugin/copy",
"plugin_excel": "/plugin/excel",
"plugin_gantt": "/plugin/gantt",
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
"plugin_gantt_vtable": "/plugin/gantt/vtable",
"plugin_icon": "/plugin/icon",
"plugin_map": "/plugin/map",
"plugin_pdf": "/plugin/pdf",
"plugin_pinyin": "/plugin/pinyin",
"plugin_print": "/plugin/print",
"plugin_swiper": "/plugin/swiper",
"plugin_tables": "/plugin/tables",
"plugin_tables_vtable": "/plugin/tables/vtable",
"plugin_typeit": "/plugin/typeit",
"plugin_video": "/plugin/video",
"product": "/product", "product": "/product",
"product_dashboard": "/product/dashboard", "product_dashboard": "/product/dashboard",
"product_list": "/product/list", "product_list": "/product/list",

View File

@@ -19,6 +19,7 @@ function createBatchDeleteQuery(ids: number[]) {
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & { type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
colorType?: string | null; colorType?: string | null;
color_type?: string | null; color_type?: string | null;
css_class?: string | null;
}; };
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & { type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
@@ -28,6 +29,7 @@ type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'>
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & { type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
colorType?: string | null; colorType?: string | null;
color_type?: string | null; color_type?: string | null;
css_class?: string | null;
}; };
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>; type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
@@ -37,20 +39,22 @@ function normalizeColorType(value?: string | null) {
} }
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData { function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data; const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return { return {
...rest, ...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase) colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
}; };
} }
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData { function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data; const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
return { return {
...rest, ...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase) colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
}; };
} }

View File

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

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

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

View File

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

View File

@@ -1,13 +1,7 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service'; import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared'; import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
import { import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`; const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
@@ -30,14 +24,14 @@ type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeAppl
list: OvertimeApplicationResponse[]; list: OvertimeApplicationResponse[];
}; };
type OvertimeApplicationStatusLogResponse = Omit< type OvertimeApplicationApprovalRecordResponse = Omit<
Api.OvertimeApplication.OvertimeApplicationStatusLog, Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot' 'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
applicationId: StringIdResponse; overtimeApplicationId: StringIdResponse;
operatorUserId: StringIdResponse; statusLogId: StringIdResponse;
overtimeDateSnapshot: ProjectLocalDateValue; auditorUserId: StringIdResponse;
}; };
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) { function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
@@ -81,18 +75,16 @@ function normalizeOvertimeApplication(
}; };
} }
function normalizeStatusLog( function normalizeApprovalRecord(
response: OvertimeApplicationStatusLogResponse response: OvertimeApplicationApprovalRecordResponse
): Api.OvertimeApplication.OvertimeApplicationStatusLog { ): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
applicationId: normalizeStringId(response.applicationId), overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
operatorUserId: normalizeStringId(response.operatorUserId), statusLogId: normalizeStringId(response.statusLogId),
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '', auditorUserId: normalizeStringId(response.auditorUserId),
fromStatus: normalizeNullableStringId(response.fromStatus), opinion: response.opinion ?? null
reason: response.reason ?? null,
remark: response.remark ?? null
}; };
} }
@@ -240,12 +232,25 @@ export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApp
}); });
} }
export function fetchCancelOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) { export function fetchBatchApproveOvertimeApplication(
return request<boolean>({ data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`, url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
method: 'post', method: 'post',
data: toStatusActionRequest(data) data
});
}
export function fetchBatchRejectOvertimeApplication(
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
) {
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
method: 'post',
data
}); });
} }
@@ -257,15 +262,28 @@ export function fetchDeleteOvertimeApplication(id: string) {
}); });
} }
export async function fetchGetOvertimeApplicationStatusLogs(id: string) { export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
const result = await request<OvertimeApplicationStatusLogResponse[]>({ const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`, url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
method: 'get' method: 'get'
}); });
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data => return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
data.map(normalizeStatusLog) data.map(normalizeApprovalRecord)
);
}
export async function fetchGetOvertimeApplicationStatusDict() {
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
data => data
); );
} }

View File

@@ -106,13 +106,34 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
})); }));
} }
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Product.OverviewStatusItem[] | null;
};
/** 归一化产品概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取产品入口页概览统计 */ /** 获取产品入口页概览统计 */
export function fetchGetProductOverviewSummary() { export async function fetchGetProductOverviewSummary() {
return request<Api.Product.ProductOverviewSummary>({ const result = await request<ProductOverviewSummaryResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/overview-summary`, url: `${PRODUCT_PREFIX}/overview-summary`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
normalizeProductOverviewSummary
);
} }
/** 获取产品详情 */ /** 获取产品详情 */

View File

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

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { normalizeNullableStringId, normalizeStringId } from './shared'; import { normalizeNullableStringId, normalizeStringId } from './shared';
type ProjectStatusCode = Api.Project.ProjectStatusCode; type ProjectStatusCode = Api.Project.ProjectStatusCode;
@@ -40,6 +41,96 @@ 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 MyTaskResponse = Omit<
Api.Project.MyTaskItem,
| 'id'
| 'projectId'
| 'executionId'
| 'priority'
| 'plannedEndDate'
| 'progressRate'
| 'createTime'
| 'parentTaskId'
| 'availableActions'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
executionId?: StringIdResponse | null;
priority?: string | number | null;
plannedEndDate?: ProjectLocalDateValue;
progressRate?: number | string | null;
createTime?: string | number | null;
parentTaskId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
};
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: TeamLoadDistributionItemResponse[] | null;
};
export type TeamLoadResponse = {
members?: TeamLoadMemberResponse[] | null;
};
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
projectId?: StringIdResponse | null;
};
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
dailyHours?: number[] | null;
distribution?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
userId: StringIdResponse;
items?: WorklogDistributionItemResponse[] | null;
};
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
members?: TeamWorklogWeekMemberResponse[] | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
@@ -227,6 +318,28 @@ export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefin
return String(value); return String(value);
} }
/**
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
*/
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '';
}
let parsed: dayjs.Dayjs;
if (typeof value === 'number') {
parsed = dayjs(value);
} else if (/^\d+$/.test(value)) {
// 字符串形态的毫秒时间戳dayjs 无法直接解析,先转数值(时间值非 ID安全整数范围内
parsed = dayjs(Number(value));
} else {
parsed = dayjs(value);
}
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
}
export function normalizeLifecycleActions<ActionCode extends string>( export function normalizeLifecycleActions<ActionCode extends string>(
actions: LifecycleActionResponse<ActionCode>[] | null | undefined actions: LifecycleActionResponse<ActionCode>[] | null | undefined
): Api.Project.LifecycleAction<ActionCode>[] { ): Api.Project.LifecycleAction<ActionCode>[] {
@@ -260,6 +373,15 @@ function normalizePriority(value: string | number | null | undefined): string {
return String(value); return String(value);
} }
function normalizeProgressRate(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(numeric) ? numeric : null;
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution { export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return { return {
...response, ...response,
@@ -286,6 +408,119 @@ 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 normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeNullableStringId(response.executionId),
executionName: response.executionName ?? null,
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
createTime: normalizeProjectDateTime(response.createTime),
parentTaskId: normalizeNullableStringId(response.parentTaskId),
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions)
};
}
function normalizeWorklogDistributionItem(
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
return {
projectId: normalizeNullableStringId(response.projectId),
projectName: response.projectName ?? null,
kind: response.kind
};
}
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
return {
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
count: typeof item.count === 'number' ? item.count : 0
})),
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
}))
};
}
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
distribution: (response.distribution ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
};
}
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
return {
weekStart: response.weekStart ?? '',
members: (response.members ?? []).map(member => ({
userId: normalizeStringId(member.userId),
userNickname: member.userNickname ?? '',
items: (member.items ?? []).map(item => ({
...normalizeWorklogDistributionItem(item),
hours: typeof item.hours === 'number' ? item.hours : 0
}))
}))
};
}
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee { export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,

View File

@@ -10,6 +10,11 @@ import {
import { import {
type ExecutionAssigneeLogResponse, type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse, type ExecutionAssigneeResponse,
type MyExecutionResponse,
type MyOwnedProjectResponse,
type MyParticipatedProjectResponse,
type MyTaskResponse,
type MyWorklogWeekResponse,
type ProjectExecutionResponse, type ProjectExecutionResponse,
type ProjectLocalDateValue, type ProjectLocalDateValue,
type ProjectMemberResponse, type ProjectMemberResponse,
@@ -17,21 +22,30 @@ import {
type TaskAssigneeFromApiResponse, type TaskAssigneeFromApiResponse,
type TaskAssigneeLogResponse, type TaskAssigneeLogResponse,
type TaskWorklogResponse, type TaskWorklogResponse,
type TeamLoadResponse,
type TeamWorklogWeekResponse,
getProjectLifecycleActions, getProjectLifecycleActions,
normalizeExecutionAssignee, normalizeExecutionAssignee,
normalizeExecutionAssigneeLog, normalizeExecutionAssigneeLog,
normalizeMyExecution,
normalizeMyOwnedProject,
normalizeMyParticipatedProject,
normalizeMyTask,
normalizeMyWorklogWeek,
normalizeProjectExecution, normalizeProjectExecution,
normalizeProjectLocalDate, normalizeProjectLocalDate,
normalizeProjectMember, normalizeProjectMember,
normalizeProjectTask, normalizeProjectTask,
normalizeTaskAssignee, normalizeTaskAssignee,
normalizeTaskAssigneeLog, normalizeTaskAssigneeLog,
normalizeTaskWorklog normalizeTaskWorklog,
normalizeTeamLoad,
normalizeTeamWorklogWeek
} from './project-shared'; } from './project-shared';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`; const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
type ProjectResponse = Omit< export type ProjectResponse = Omit<
Api.Project.Project, Api.Project.Project,
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate' 'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
> & { > & {
@@ -73,7 +87,7 @@ function getTaskPrefix(projectId: string, executionId: string) {
} }
/** 归一化项目数据 */ /** 归一化项目数据 */
function normalizeProject(project: ProjectResponse): Api.Project.Project { export function normalizeProject(project: ProjectResponse): Api.Project.Project {
return { return {
...project, ...project,
id: normalizeStringId(project.id), id: normalizeStringId(project.id),
@@ -130,13 +144,34 @@ export async function fetchGetProjectPage(params?: Api.Project.ProjectSearchPara
})); }));
} }
type ProjectOverviewSummaryResponse = Omit<Api.Project.ProjectOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Project.OverviewStatusItem[] | null;
};
/** 归一化项目概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProjectOverviewSummary(data: ProjectOverviewSummaryResponse): Api.Project.ProjectOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取项目入口页概览统计 */ /** 获取项目入口页概览统计 */
export function fetchGetProjectOverviewSummary() { export async function fetchGetProjectOverviewSummary() {
return request<Api.Project.ProjectOverviewSummary>({ const result = await request<ProjectOverviewSummaryResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/overview-summary`, url: `${PROJECT_PREFIX}/overview-summary`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<ProjectOverviewSummaryResponse>,
normalizeProjectOverviewSummary
);
} }
/** 获取项目详情 */ /** 获取项目详情 */
@@ -365,6 +400,105 @@ export async function fetchGetProjectExecutionPage(
})); }));
} }
/** 获取工作台「我负责的执行」跨项目聚合owner 隐式取当前登录用户) */
export async function fetchGetMyExecutionPage(params?: Api.Project.MyExecutionSearchParams) {
type MyExecutionPageResponse = Api.Project.PageResult<MyExecutionResponse>;
const result = await request<MyExecutionPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/executions/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyExecutionPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyExecution)
}));
}
/** 获取工作台「我参与的项目」(成员视角,附我的角色与任务量;隐式取当前登录用户) */
export async function fetchGetMyParticipatedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyParticipatedProjectPageResponse = Api.Project.PageResult<MyParticipatedProjectResponse>;
const result = await request<MyParticipatedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/participated/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyParticipatedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyParticipatedProject)
}));
}
/** 获取工作台「我负责的项目」(项目负责人视角,附聚合统计与成员负载;隐式取当前登录用户) */
export async function fetchGetMyOwnedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyOwnedProjectPageResponse = Api.Project.PageResult<MyOwnedProjectResponse>;
const result = await request<MyOwnedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/owned/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyOwnedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyOwnedProject)
}));
}
/** 获取工作台「我的任务」(跨项目聚合,负责人/在岗协办人口径,只返回非终态;隐式取当前登录用户) */
export async function fetchGetMyTaskPage(params?: Api.Project.MyTaskSearchParams) {
type MyTaskPageResponse = Api.Project.PageResult<MyTaskResponse>;
const result = await request<MyTaskPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/tasks/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyTaskPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyTask)
}));
}
/** 获取工作台「团队负载」(团队 = 当前用户 + 管理链路直接下级members[0] 恒为当前用户) */
export async function fetchGetMyTeamLoad() {
const result = await request<TeamLoadResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-load`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TeamLoadResponse>, normalizeTeamLoad);
}
/** 获取工作台「我的工时周聚合」weekStart 传任意日期,后端归一到所在周周一;逐日工时为均摊推算值) */
export async function fetchGetMyWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<MyWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyWorklogWeekResponse>, normalizeMyWorklogWeek);
}
/** 获取工作台「团队工时周聚合」(成员集合与团队负载同口径;周标准工时后端不返回,前端落常量) */
export async function fetchGetTeamWorklogWeek(params: Api.Project.WorklogWeekParams) {
const result = await request<TeamWorklogWeekResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/team-worklog-week`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamWorklogWeekResponse>, normalizeTeamWorklogWeek);
}
/** 获取项目执行状态看板 */ /** 获取项目执行状态看板 */
export function fetchGetProjectExecutionStatusBoard( export function fetchGetProjectExecutionStatusBoard(
projectId: string, projectId: string,

View File

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

View File

@@ -12,11 +12,13 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const REQUEST_TIMEOUT = 15 * 1000;
export const request = withDedupe( export const request = withDedupe(
createFlatRequest( createFlatRequest(
{ {
baseURL, baseURL,
timeout: REQUEST_TIMEOUT,
headers: { headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2' apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
} }
@@ -126,6 +128,10 @@ export const request = withDedupe(
let message = error.message; let message = error.message;
let backendErrorCode = ''; let backendErrorCode = '';
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
message = '请求超时,请稍后重试';
}
// 获取后端错误信息和错误码 // 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) { if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message; message = error.response?.data?.msg || message;

View File

@@ -131,6 +131,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// If the tab needs to be cleared,it means we don't need to redirect. // If the tab needs to be cleared,it means we don't need to redirect.
needRedirect = false; needRedirect = false;
} }
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
await routeStore.initAuthRoute();
await redirectFromLogin(needRedirect); await redirectFromLogin(needRedirect);
window.$notification?.success({ window.$notification?.success({

View File

@@ -28,6 +28,15 @@ function normalizeColorType(raw: unknown): string | null {
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null; return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
} }
/**
* 解析字典项最终展示色hex
* 精确色 cssClass 优先(覆盖 colorType 落到语义色无法区分黄/橙等场景),其次 colorType
* 两者都不是合法 hex 时回落 null默认渲染
*/
function resolveDisplayColor(colorType: unknown, cssClass: unknown): string | null {
return normalizeColorType(cssClass) ?? normalizeColorType(colorType);
}
function normalizeFrontendDictData( function normalizeFrontendDictData(
dictType: string, dictType: string,
list: Api.Dict.FrontendDictData[], list: Api.Dict.FrontendDictData[],
@@ -40,7 +49,7 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
sort: item.sort, sort: item.sort,
status: item.status ?? 0, status: item.status ?? 0,
colorType: normalizeColorType(item.colorType), colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null, remark: item.remark ?? null,
createTime: 0 createTime: 0
})); }));
@@ -54,7 +63,7 @@ function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.D
value: String(item.value), value: String(item.value),
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
status: item.status ?? 0, status: item.status ?? 0,
colorType: normalizeColorType(item.colorType), colorType: resolveDisplayColor(item.colorType, item.cssClass),
remark: item.remark ?? null remark: item.remark ?? null
}; };
} }

View File

@@ -406,6 +406,7 @@ html .el-collapse {
.business-table-action-cell { .business-table-action-cell {
display: flex; display: flex;
width: 100%; width: 100%;
box-sizing: border-box;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 0 8px; padding: 0 8px;

View File

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

View File

@@ -57,6 +57,8 @@ declare namespace Api {
status: DictStatus; status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */ /** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null; colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType用于 colorType 落到语义色无法区分的场景 */
cssClass?: string | null;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** create time */ /** create time */
@@ -77,6 +79,8 @@ declare namespace Api {
status?: DictStatus; status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */ /** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null; colorType?: string | null;
/** 精确颜色hex#xxxxxx存在时优先于 colorType */
cssClass?: string | null;
/** 备注,可用于下拉中文释义展示 */ /** 备注,可用于下拉中文释义展示 */
remark?: string | null; remark?: string | null;
} }

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

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

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

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

View File

@@ -5,9 +5,9 @@ declare namespace Api {
pageSize: number; pageSize: number;
} }
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected' | 'cancelled'; type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected';
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject' | 'cancel'; type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject';
interface OvertimeApplication { interface OvertimeApplication {
id: string; id: string;
@@ -59,20 +59,41 @@ declare namespace Api {
reason?: string | null; reason?: string | null;
} }
interface OvertimeApplicationStatusLog { interface OvertimeApplicationBatchActionParams {
id: string; ids: string[];
applicationId: string;
actionType: OvertimeApplicationActionType;
fromStatus?: string | null;
toStatus: string;
reason?: string | null; reason?: string | null;
operatorUserId: string; }
operatorName: string;
applicantNameSnapshot: string; interface OvertimeApplicationBatchFailItem {
overtimeDateSnapshot: string; id: string;
overtimeDurationSnapshot: string; reason: string;
remark?: string | null; }
interface OvertimeApplicationBatchActionResult {
successCount: number;
failCount: number;
failItems: OvertimeApplicationBatchFailItem[];
}
interface OvertimeApplicationApprovalRecord {
id: string;
overtimeApplicationId: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string; createTime: string;
} }
interface OvertimeApplicationStatusDict {
statusCode: string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
} }
} }

View File

@@ -21,10 +21,27 @@ declare namespace Api {
list: T[]; list: T[];
} }
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true产品列表暂无"全部"视图,按同构契约返回) */
includeInAll: boolean;
}
/** 产品入口页概览统计 */ /** 产品入口页概览统计 */
interface ProductOverviewSummary { interface ProductOverviewSummary {
/** 产品状态数量映射key 为后端状态编码 */ /** 产品状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和 */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
} }
interface Product { interface Product {
@@ -172,8 +189,10 @@ declare namespace Api {
type ProductSearchParams = CommonType.RecordNullable< type ProductSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & { Pick<Product, 'directionCode' | 'managerUserId'> & {
keyword: string; keyword: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
updateTime: string[]; updateTime: string[];
} }
>; >;

View File

@@ -304,6 +304,213 @@ 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[];
}
/** 工作台「我的任务」(跨项目)查询入参 */
type MyTaskSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 身份过滤owner 我负责 / collaborator 我协办;缺省 = 两者并集 */
involveType: 'owner' | 'collaborator';
}
>;
/** 工作台「我的任务」单项(跨项目;当前用户为负责人或在岗协办人,接口只返回非终态任务) */
interface MyTaskItem {
/** 任务 ID雪花 ID字符串 */
id: string;
taskTitle: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 所属执行,未挂执行为 null */
executionId: string | null;
executionName: string | null;
/** 任务状态pending / active / paused非终态 */
statusCode: ProjectTaskStatusCode;
statusName: string | null;
/** 优先级字典 valuerdms_req_priority"0"~"3",数字越小越高) */
priority: string;
/** 计划结束日期YYYY-MM-DD可空 */
plannedEndDate: string | null;
/** 任务进度0-100后端定稿直接返回无进度明确返 0 */
progressRate: number;
/** 创建时间YYYY-MM-DD HH:mm:ss后端返毫秒时间戳适配层归一 */
createTime: string;
/** 我的角色owner 负责人 / collaborator 协办人;双重身份只返 owner */
myRole: 'owner' | 'collaborator';
/** 父任务 ID字符串一级任务为 null */
parentTaskId: string | null;
/** 是否终态;本接口只返非终态任务,正常恒为 false */
terminal: boolean;
/** 当前状态是否允许编辑任务 */
allowEdit: boolean;
/** 当前登录用户可执行的生命周期动作与任务详情同口径auto_start 不返回),无动作为 [] */
availableActions: LifecycleAction<ProjectTaskActionCode>[];
}
/** 工作台「团队负载」分布子项kind != project 时 projectId / projectName 为 null */
interface TeamLoadDistributionItem {
projectId: string | null;
projectName: string | null;
/** project 项目任务 / personal 个人事项 / other 无法归类的残留 */
kind: 'project' | 'personal' | 'other';
/** 未完成任务数(含待开始/已暂停) */
count: number;
}
/** 工作台「团队负载」成员members[0] 恒为当前用户) */
interface TeamLoadMember {
/** 用户 ID字符串 */
userId: string;
userNickname: string;
/** 未完成任务按归属分布,无任务为 [] */
items: TeamLoadDistributionItem[];
/** 临期:今天 ≤ 计划结束 ≤ 今天+3 天,且未完成(与逾期互斥) */
dueSoonCount: number;
/** 逾期:计划结束 < 今天,且未完成 */
overdueCount: number;
}
/** 工作台「团队负载」响应GET /project/project/me/team-load团队 = 自己 + 管理链路直接下级) */
interface TeamLoadResult {
members: TeamLoadMember[];
}
/** 工作台工时分布子项kind != project 时 projectId / projectName 为 nullhours=0 的行后端不输出) */
interface WorklogDistributionItem {
projectId: string | null;
projectName: string | null;
kind: 'project' | 'personal' | 'other';
hours: number;
}
/** 工作台「我的工时周聚合」响应GET /project/project/me/worklog-week */
interface MyWorklogWeekResult {
/** 归一后的周一日期 YYYY-MM-DD */
weekStart: string;
/** 周一~周五逐日工时(固定 5 元素;均摊推算值,周末份额归周五) */
dailyHours: number[];
/** 本周工时按归属分布hours 降序 */
distribution: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」成员members[0] 恒为当前用户;该周未填报成员 items 为 [] */
interface TeamWorklogWeekMember {
userId: string;
userNickname: string;
items: WorklogDistributionItem[];
}
/** 工作台「团队工时周聚合」响应GET /project/project/me/team-worklog-week周标准工时后端不返回前端落常量 35 */
interface TeamWorklogWeekResult {
weekStart: string;
members: TeamWorklogWeekMember[];
}
/** 工作台工时周聚合查询入参weekStart 传任意日期,后端归一到所在周周一) */
interface WorklogWeekParams {
weekStart: string;
}
/** 创建执行入参(含 ownerId + assigneeUserIds */ /** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams { interface CreateProjectExecutionParams {
executionName: string; executionName: string;
@@ -580,10 +787,29 @@ declare namespace Api {
list: T[]; list: T[];
} }
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag不能用于"全部"排除或左栏分区completed 也可能是终态) */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true将来恢复排除项由该字段表达 */
includeInAll: boolean;
}
/** 项目入口页概览统计 */ /** 项目入口页概览统计 */
interface ProjectOverviewSummary { interface ProjectOverviewSummary {
/** 项目状态数量映射key 为后端状态编码 */ /** 项目状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和(作废/归档计入) */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
/** 游离项目计数 = 所有未挂产品的项目(不按状态过滤),左栏游离入口据此显隐 */
orphanCount?: number;
} }
interface Project { interface Project {
@@ -686,11 +912,75 @@ declare namespace Api {
projectType: string; projectType: string;
productId: string; productId: string;
managerUserId: string; managerUserId: string;
statusCode: ProjectStatusCode; /** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
/** 多值状态筛选(存在时后端优先于单值 statusCode分组页"展开剩余"按"全部"口径传 items 派生的全量编码 */
statusCodes: string[];
/** 仅查游离项目productId 为空);与 productId 互斥,分组页展开游离组剩余时用 */
orphanOnly: boolean;
updateTime: string[]; updateTime: string[];
} }
>; >;
/**
* 项目列表"按产品分组"查询入参GET /project/project/group-page
*
* - pageNo / pageSize 为**产品组维度**分页(一页 M 个产品组),不是项目行分页。
* - statusCode 不传 = "全部"视图后端从状态机推导2026-06-11 口径变更后无排除项,作废/归档计入)。
* - orphanOnly = true 仅返回游离组productId 为空的项目);不可与 productId 同传。
* - topN每组返回项目条数上限后端默认 5范围 1~50超出由页面展开拉取。
*/
type ProjectGroupSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
productId: string;
projectType: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
orphanOnly: boolean;
topN: number;
}
>;
/** 按产品聚合的项目分组 */
interface ProjectGroup {
/** 产品 ID游离组为 null */
productId: string | null;
/** 产品名称;游离组固定为"游离项目" */
productName: string;
/** 产品编码;游离组为 null */
productCode: string | null;
/** 产品方向字典值;游离组为空串 */
directionCode: string;
/** 产品经理用户 ID */
managerUserId: string | null;
/** 产品经理昵称(后端回填;游离组为 null前端 managerLabelMap 兜底) */
managerUserNickname: string | null;
/** 当前筛选口径下组内项目总数 */
projectTotal: number;
/** 组内项目前 topN 条,按最近更新倒序;剩余由页面按 productId/orphanOnly + statusCodes 走 page 接口展开拉取 */
projects: Project[];
/** 组内按项目类型字典 value 的计数(现状按"全部口径"统计;已提需求改为跟随 statusCode 与 projectTotal 同口径,后端落地后更新本注释) */
typeCounts: Record<string, number>;
/** 是否已有主线项目(口径=存在非已取消 cancelled 的主线,已归档/完成也算占坑);前端直接消费、不用 typeCounts 推导 */
hasBaseline: boolean;
/** 是否游离组(未挂产品) */
orphan: boolean;
}
/** 产品分组分页结果 */
interface ProjectGroupPageResult {
/** 当前筛选口径下产品组总数(分页 total含游离组 */
total: number;
/** 当前筛选口径下项目总数(标题 meta 用) */
projectTotal: number;
/** 当前筛选口径下可见产品跨方向数≥2 时前端渲染方向层) */
directionCount: number;
/** 当前筛选口径下游离项目数(标题/分页用);左栏常驻游离计数改用 overview-summary 的 orphanCount 全口径 */
orphanTotal: number;
list: ProjectGroup[];
}
/** 创建/保存项目参数 */ /** 创建/保存项目参数 */
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & { type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
projectCode: string | null; projectCode: string | null;

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

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

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

@@ -129,6 +129,8 @@ declare module 'vue' {
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default'] IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default'] IconLocalActivity: typeof import('~icons/local/activity')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalCast: typeof import('~icons/local/cast')['default'] IconLocalCast: typeof import('~icons/local/cast')['default']

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";
@@ -46,33 +36,14 @@ declare module "@elegant-router/types" {
"personal-center": "/personal-center"; "personal-center": "/personal-center";
"personal-center_my-application": "/personal-center/my-application"; "personal-center_my-application": "/personal-center/my-application";
"personal-center_my-item": "/personal-center/my-item"; "personal-center_my-item": "/personal-center/my-item";
"personal-center_my-monthly": "/personal-center/my-monthly";
"personal-center_my-performance": "/personal-center/my-performance"; "personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile"; "personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_overtime-application": "/personal-center/overtime-application"; "personal-center_overtime-application": "/personal-center/overtime-application";
"personal-center_pending-approval": "/personal-center/pending-approval"; "personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin"; "personal-center_work-report": "/personal-center/work-report";
"plugin_barcode": "/plugin/barcode"; "personal-center_work-report_monthly": "/personal-center/work-report/monthly";
"plugin_charts": "/plugin/charts"; "personal-center_work-report_project": "/personal-center/work-report/project";
"plugin_charts_antv": "/plugin/charts/antv"; "personal-center_work-report_weekly": "/personal-center/work-report/weekly";
"plugin_charts_echarts": "/plugin/charts/echarts";
"plugin_charts_vchart": "/plugin/charts/vchart";
"plugin_copy": "/plugin/copy";
"plugin_excel": "/plugin/excel";
"plugin_gantt": "/plugin/gantt";
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
"plugin_gantt_vtable": "/plugin/gantt/vtable";
"plugin_icon": "/plugin/icon";
"plugin_map": "/plugin/map";
"plugin_pdf": "/plugin/pdf";
"plugin_pinyin": "/plugin/pinyin";
"plugin_print": "/plugin/print";
"plugin_swiper": "/plugin/swiper";
"plugin_tables": "/plugin/tables";
"plugin_tables_vtable": "/plugin/tables/vtable";
"plugin_typeit": "/plugin/typeit";
"plugin_video": "/plugin/video";
"product": "/product"; "product": "/product";
"product_dashboard": "/product/dashboard"; "product_dashboard": "/product/dashboard";
"product_list": "/product/list"; "product_list": "/product/list";
@@ -135,13 +106,11 @@ declare module "@elegant-router/types" {
| "403" | "403"
| "404" | "404"
| "500" | "500"
| "function"
| "iframe-page" | "iframe-page"
| "infra" | "infra"
| "login" | "login"
| "metrics" | "metrics"
| "personal-center" | "personal-center"
| "plugin"
| "product" | "product"
| "project" | "project"
| "system" | "system"
@@ -169,14 +138,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"
@@ -184,29 +145,14 @@ declare module "@elegant-router/types" {
| "metrics_worktime" | "metrics_worktime"
| "personal-center_my-application" | "personal-center_my-application"
| "personal-center_my-item" | "personal-center_my-item"
| "personal-center_my-monthly"
| "personal-center_my-performance" | "personal-center_my-performance"
| "personal-center_my-profile" | "personal-center_my-profile"
| "personal-center_my-weekly"
| "personal-center_overtime-application" | "personal-center_overtime-application"
| "personal-center_pending-approval" | "personal-center_pending-approval"
| "plugin_barcode" | "personal-center_work-report"
| "plugin_charts_antv" | "personal-center_work-report_monthly"
| "plugin_charts_echarts" | "personal-center_work-report_project"
| "plugin_charts_vchart" | "personal-center_work-report_weekly"
| "plugin_copy"
| "plugin_excel"
| "plugin_gantt_dhtmlx"
| "plugin_gantt_vtable"
| "plugin_icon"
| "plugin_map"
| "plugin_pdf"
| "plugin_pinyin"
| "plugin_print"
| "plugin_swiper"
| "plugin_tables_vtable"
| "plugin_typeit"
| "plugin_video"
| "product_dashboard" | "product_dashboard"
| "product_list" | "product_list"
| "product_requirement" | "product_requirement"

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,20 +1,14 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { computed, markRaw, reactive, ref } from 'vue'; import { computed, markRaw, reactive, ref } from 'vue';
import { ElButton, ElMessageBox, ElTag } from 'element-plus'; import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import { fetchExportOvertimeApplications, fetchGetOvertimeApplicationPage } from '@/service/api';
fetchCancelOvertimeApplication,
fetchDeleteOvertimeApplication,
fetchExportOvertimeApplications,
fetchGetOvertimeApplicationPage
} from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table'; import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell'; import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import OvertimeApplicationActionDialog from './modules/overtime-application-action-dialog.vue'; import OvertimeApplicationApprovalRecordDialog from './modules/overtime-application-approval-record-dialog.vue';
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue'; import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue'; import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
import OvertimeApplicationSearch from './modules/overtime-application-search.vue'; import OvertimeApplicationSearch from './modules/overtime-application-search.vue';
import OvertimeApplicationStatusLogDialog from './modules/overtime-application-status-log-dialog.vue';
import { import {
downloadBlob, downloadBlob,
formatEmptyText, formatEmptyText,
@@ -23,16 +17,13 @@ import {
getOvertimeApplicationStatusLabel, getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType resolveOvertimeApplicationStatusTagType
} from './modules/overtime-application-shared'; } from './modules/overtime-application-shared';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline'; import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiHistory from '~icons/mdi/history'; import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline'; import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'OvertimeApplication' }); defineOptions({ name: 'OvertimeApplication' });
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>; type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
type ActionType = 'cancel';
function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams { function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams {
return { return {
@@ -69,20 +60,15 @@ function transformPageResult(response: OvertimeApplicationPageResponse, pageNo:
const searchParams = reactive(getInitSearchParams()); const searchParams = reactive(getInitSearchParams());
const operateVisible = ref(false); const operateVisible = ref(false);
const detailVisible = ref(false); const detailVisible = ref(false);
const statusLogVisible = ref(false); const approvalRecordVisible = ref(false);
const actionVisible = ref(false);
const operateType = ref<'add' | 'edit'>('add'); const operateType = ref<'add' | 'edit'>('add');
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null); const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const currentActionType = ref<ActionType>('cancel');
const actionSubmitting = ref(false);
const exporting = ref(false); const exporting = ref(false);
const ACTION_ICON_MAP = { const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline), detail: markRaw(IconMdiEyeOutline),
statusLog: markRaw(IconMdiHistory), approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
edit: markRaw(IconMdiPencilOutline), edit: markRaw(IconMdiPencilOutline)
cancel: markRaw(IconMdiCloseCircleOutline),
delete: markRaw(IconMdiDeleteOutline)
}; };
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
@@ -113,14 +99,14 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
prop: 'overtimeReason', prop: 'overtimeReason',
label: '加班原因', label: '加班原因',
minWidth: 180, minWidth: 180,
showOverflowTooltip: true, className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeReason) formatter: row => formatEmptyText(row.overtimeReason)
}, },
{ {
prop: 'overtimeContent', prop: 'overtimeContent',
label: '加班内容', label: '加班内容',
minWidth: 200, minWidth: 200,
showOverflowTooltip: true, className: 'overtime-application__cell-ellipsis',
formatter: row => formatEmptyText(row.overtimeContent) formatter: row => formatEmptyText(row.overtimeContent)
}, },
{ {
@@ -134,17 +120,17 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
</ElTag> </ElTag>
) )
}, },
{ prop: 'approverName', label: '审核人', minWidth: 120, showOverflowTooltip: true }, { prop: 'approverName', label: '审核人', minWidth: 80, showOverflowTooltip: true },
{ {
prop: 'submitTime', prop: 'submitTime',
label: '提交时间', label: '提交时间',
minWidth: 170, minWidth: 150,
formatter: row => formatOvertimeDateTime(row.submitTime) formatter: row => formatOvertimeDateTime(row.submitTime)
}, },
{ {
prop: 'approvalTime', prop: 'approvalTime',
label: '审核时间', label: '审核时间',
minWidth: 170, minWidth: 150,
formatter: row => formatOvertimeDateTime(row.approvalTime) formatter: row => formatOvertimeDateTime(row.approvalTime)
}, },
{ {
@@ -171,7 +157,7 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
} }
]; ];
if ((row.statusCode === 'rejected' || row.statusCode === 'cancelled') && row.allowEdit) { if (row.statusCode === 'rejected' && row.allowEdit) {
actions.push({ actions.push({
key: 'edit', key: 'edit',
label: '修改', label: '修改',
@@ -181,31 +167,13 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
}); });
} }
actions.push({ if (['approved', 'rejected'].includes(row.statusCode)) {
key: 'status-log',
label: '状态日志',
buttonType: 'info',
icon: ACTION_ICON_MAP.statusLog,
onClick: () => openStatusLog(row)
});
if (row.statusCode === 'pending') {
actions.push({ actions.push({
key: 'cancel', key: 'approval-record',
label: '撤销', label: '审批记录',
buttonType: 'danger', buttonType: 'info',
icon: ACTION_ICON_MAP.cancel, icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openCancel(row) onClick: () => openApprovalRecord(row)
});
}
if (row.statusCode === 'cancelled') {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
onClick: () => handleDelete(row)
}); });
} }
@@ -229,15 +197,9 @@ function openDetail(row: Api.OvertimeApplication.OvertimeApplication) {
detailVisible.value = true; detailVisible.value = true;
} }
function openStatusLog(row: Api.OvertimeApplication.OvertimeApplication) { function openApprovalRecord(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row; currentRow.value = row;
statusLogVisible.value = true; approvalRecordVisible.value = true;
}
function openCancel(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
currentActionType.value = 'cancel';
actionVisible.value = true;
} }
async function reloadTable(page = searchParams.pageNo ?? 1) { async function reloadTable(page = searchParams.pageNo ?? 1) {
@@ -259,49 +221,6 @@ function handleSubmitted() {
reloadTable(searchParams.pageNo ?? 1); reloadTable(searchParams.pageNo ?? 1);
} }
async function handleActionSubmit(reason: string | null) {
if (!currentRow.value) {
return;
}
actionSubmitting.value = true;
const { error } = await fetchCancelOvertimeApplication(currentRow.value.id, { reason });
actionSubmitting.value = false;
if (error) {
return;
}
actionVisible.value = false;
window.$message?.success('加班申请已撤销');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleDelete(row: Api.OvertimeApplication.OvertimeApplication) {
try {
await ElMessageBox.confirm(
`确定删除 ${row.applicantName} ${formatOvertimeDate(row.overtimeDate)} 的加班申请吗?`,
'删除确认',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
}
);
} catch {
return;
}
const { error } = await fetchDeleteOvertimeApplication(row.id);
if (error) {
return;
}
window.$message?.success('加班申请已删除');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleExport() { async function handleExport() {
exporting.value = true; exporting.value = true;
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams); const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
@@ -373,14 +292,7 @@ async function handleExport() {
<OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" /> <OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" />
<OvertimeApplicationStatusLogDialog v-model:visible="statusLogVisible" :row-data="currentRow" /> <OvertimeApplicationApprovalRecordDialog v-model:visible="approvalRecordVisible" :row-data="currentRow" />
<OvertimeApplicationActionDialog
v-model:visible="actionVisible"
:action-type="currentActionType"
:loading="actionSubmitting"
@submit="handleActionSubmit"
/>
</div> </div>
</template> </template>
@@ -398,4 +310,12 @@ async function handleExport() {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* 加班原因/加班内容:单元格内容溢出时仅显示省略号,不弹出 tooltip */
:deep(.overtime-application__cell-ellipsis .cell) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>

View File

@@ -5,7 +5,7 @@ import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'OvertimeApplicationActionDialog' }); defineOptions({ name: 'OvertimeApplicationActionDialog' });
type ActionType = 'approve' | 'reject' | 'cancel'; type ActionType = 'approve' | 'reject';
interface Props { interface Props {
actionType: ActionType; actionType: ActionType;
@@ -34,8 +34,7 @@ const model = reactive({
const title = computed(() => { const title = computed(() => {
const map: Record<ActionType, string> = { const map: Record<ActionType, string> = {
approve: '通过加班申请', approve: '通过加班申请',
reject: '退回加班申请', reject: '退回加班申请'
cancel: '撤销加班申请'
}; };
return map[props.actionType]; return map[props.actionType];
@@ -44,8 +43,7 @@ const title = computed(() => {
const reasonLabel = computed(() => { const reasonLabel = computed(() => {
const map: Record<ActionType, string> = { const map: Record<ActionType, string> = {
approve: '审核意见', approve: '审核意见',
reject: '退回原因', reject: '退回原因'
cancel: '撤销原因'
}; };
return map[props.actionType]; return map[props.actionType];
@@ -58,7 +56,7 @@ const reasonPlaceholder = computed(() => {
return `请输入${reasonLabel.value}`; return `请输入${reasonLabel.value}`;
} }
return props.actionType === 'cancel' ? '可填写撤销原因' : '可填写审核意见'; return '可填写审核意见';
}); });
const rules = computed(() => ({ const rules = computed(() => ({

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { fetchGetOvertimeApplicationApprovalRecords } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
formatEmptyText,
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationApprovalRecordDialog' });
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const records = ref<Api.OvertimeApplication.OvertimeApplicationApprovalRecord[]>([]);
async function loadRecords() {
if (!props.rowData?.id) {
records.value = [];
return;
}
loading.value = true;
const { error, data } = await fetchGetOvertimeApplicationApprovalRecords(props.rowData.id);
loading.value = false;
records.value = error || !data ? [] : data;
}
watch(
() => visible.value,
value => {
if (value) {
loadRecords();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="加班申请审批记录"
width="820px"
:loading="loading"
:show-footer="false"
max-body-height="72vh"
>
<ElTable border :data="records">
<ElTableColumn prop="approvalRound" label="轮次" width="80" />
<ElTableColumn label="结论" width="110">
<template #default="{ row }">{{ getOvertimeApplicationStatusLabel(row.conclusion) }}</template>
</ElTableColumn>
<ElTableColumn label="审批意见" min-width="240" show-overflow-tooltip>
<template #default="{ row }">{{ formatEmptyText(row.opinion) }}</template>
</ElTableColumn>
<ElTableColumn prop="auditorName" label="审批人" width="130" show-overflow-tooltip />
<ElTableColumn label="审批时间" width="170">
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
</ElTableColumn>
</ElTable>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
import IconMdiChevronRight from '~icons/mdi/chevron-right';
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
interface Props {
/** 选中的加班申请 id 列表(原始 id */
selectedIds: string[];
/** 全部加班申请行数据,用于通过 id 查找 */
rows: Api.OvertimeApplication.OvertimeApplication[];
actionLoading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
actionLoading: false
});
const emit = defineEmits<{
approve: [];
reject: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const currentIndex = ref(0);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const detailLoading = ref(false);
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
const total = computed(() => props.selectedIds.length);
const canGoPrev = computed(() => currentIndex.value > 0);
const canGoNext = computed(() => currentIndex.value < props.selectedIds.length - 1);
async function loadDetail() {
const id = currentId.value;
if (!id) {
detailData.value = null;
return;
}
const row = props.rows.find(r => r.id === id);
if (!row) {
detailData.value = null;
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetOvertimeApplicationDetail(id);
detailLoading.value = false;
detailData.value = error || !data ? row : data;
}
function goPrev() {
if (!canGoPrev.value) return;
currentIndex.value -= 1;
loadDetail();
}
function goNext() {
if (!canGoNext.value) return;
currentIndex.value += 1;
loadDetail();
}
watch(
() => visible.value,
value => {
if (value) {
currentIndex.value = 0;
loadDetail();
} else {
detailData.value = null;
}
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="批量审批" preset="md" :loading="detailLoading" :show-footer="true">
<!-- 左右导航 -->
<div class="batch-detail__nav">
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoPrev" @click.stop="goPrev">
<IconMdiChevronLeft class="text-20px" />
</button>
<span class="batch-detail__nav-counter">{{ currentIndex + 1 }} / {{ total }}</span>
<button type="button" class="batch-detail__nav-btn" :disabled="!canGoNext" @click.stop="goNext">
<IconMdiChevronRight class="text-20px" />
</button>
</div>
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.applicantName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDate(detailData.overtimeDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeDuration }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDateTime(detailData.submitTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeReason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeContent }}
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
<template #footer>
<div class="batch-detail__footer">
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
<div class="batch-detail__footer-actions">
<ElButton
class="batch-detail__approve-btn"
type="success"
:loading="props.actionLoading"
:disabled="props.actionLoading || !detailData"
@click="emit('approve')"
>
<template #icon>
<IconMdiCheckCircleOutline />
</template>
通过
</ElButton>
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
<template #icon>
<IconMdiCloseCircleOutline />
</template>
退回
</ElButton>
</div>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.batch-detail__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 16px;
}
.batch-detail__nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 8px;
background-color: rgb(255 255 255 / 98%);
color: rgb(71 85 105 / 94%);
cursor: pointer;
transition: all 160ms ease;
}
.batch-detail__nav-btn:hover:not(:disabled) {
border-color: rgb(14 116 144 / 60%);
color: rgb(14 116 144 / 96%);
}
.batch-detail__nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.batch-detail__nav-counter {
font-size: 14px;
font-weight: 600;
color: rgb(15 23 42 / 96%);
min-width: 60px;
text-align: center;
}
.batch-detail__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.batch-detail__footer-hint {
font-size: 13px;
color: rgb(100 116 139 / 92%);
}
.batch-detail__footer-actions {
display: flex;
gap: 12px;
}
.batch-detail__approve-btn {
--el-button-bg-color: #0f766e;
--el-button-border-color: #0f766e;
--el-button-hover-bg-color: #115e59;
--el-button-hover-border-color: #115e59;
--el-button-active-bg-color: #134e4a;
--el-button-active-border-color: #134e4a;
}
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
line-height: 1.7;
}
:deep(.overtime-application-detail-dialog__label),
:deep(.overtime-application-detail-dialog__label--compact) {
white-space: nowrap;
vertical-align: middle;
}
:deep(.overtime-application-detail-dialog__label) {
width: 96px;
min-width: 96px;
}
:deep(.overtime-application-detail-dialog__label--compact) {
width: 86px;
min-width: 86px;
}
</style>

View File

@@ -1,22 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api'; import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
formatEmptyText, import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
formatOvertimeDate, import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationDetailDialog' }); defineOptions({ name: 'OvertimeApplicationDetailDialog' });
interface Props { interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null; rowData?: Api.OvertimeApplication.OvertimeApplication | null;
showApprovalActions?: boolean;
actionLoading?: boolean;
} }
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
showApprovalActions: false,
actionLoading: false
});
const emit = defineEmits<{
approve: [];
reject: [];
}>();
const visible = defineModel<boolean>('visible', { const visible = defineModel<boolean>('visible', {
default: false default: false
@@ -25,11 +31,6 @@ const visible = defineModel<boolean>('visible', {
const loading = ref(false); const loading = ref(false);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null); const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const statusTagType = computed(() => resolveOvertimeApplicationStatusTagType(detailData.value?.statusCode));
const statusLabel = computed(() =>
getOvertimeApplicationStatusLabel(detailData.value?.statusCode, detailData.value?.statusName)
);
async function loadDetail() { async function loadDetail() {
if (!props.rowData?.id) { if (!props.rowData?.id) {
detailData.value = null; detailData.value = null;
@@ -54,30 +55,96 @@ watch(
</script> </script>
<template> <template>
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading" :show-footer="false"> <BusinessFormDialog
<ElDescriptions v-if="detailData" :column="2" border> v-model="visible"
<ElDescriptionsItem label="状态"> title="加班申请详情"
<ElTag :type="statusTagType">{{ statusLabel }}</ElTag> preset="md"
</ElDescriptionsItem> :loading="loading"
<ElDescriptionsItem label="申请人"> :show-footer="props.showApprovalActions"
>
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.applicantName }} {{ detailData.applicantName }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="加班日期">{{ formatOvertimeDate(detailData.overtimeDate) }}</ElDescriptionsItem> <ElDescriptionsItem label="加班日期" label-class-name="overtime-application-detail-dialog__label--compact">
<ElDescriptionsItem label="加班时长">{{ detailData.overtimeDuration }}</ElDescriptionsItem> {{ formatOvertimeDate(detailData.overtimeDate) }}
<ElDescriptionsItem label="审核人"> </ElDescriptionsItem>
{{ detailData.approverName }} <ElDescriptionsItem label="加班时长" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeDuration }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间" label-class-name="overtime-application-detail-dialog__label--compact">
{{ formatOvertimeDateTime(detailData.submitTime) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeReason }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2" label-class-name="overtime-application-detail-dialog__label">
{{ detailData.overtimeContent }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ formatOvertimeDateTime(detailData.submitTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核时间">{{ formatOvertimeDateTime(detailData.approvalTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核意见">{{ formatEmptyText(detailData.approvalComment) }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2">{{ detailData.overtimeReason }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2">{{ detailData.overtimeContent }}</ElDescriptionsItem>
</ElDescriptions> </ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" /> <ElEmpty v-else description="未获取到加班申请详情" />
<template #footer>
<div class="overtime-application-detail-dialog__footer">
<ElButton
class="overtime-application-detail-dialog__approve-btn"
type="success"
:loading="props.actionLoading"
:disabled="props.actionLoading || !detailData"
@click="emit('approve')"
>
<template #icon>
<IconMdiCheckCircleOutline />
</template>
通过
</ElButton>
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
<template #icon>
<IconMdiCloseCircleOutline />
</template>
退回
</ElButton>
</div>
</template>
</BusinessFormDialog> </BusinessFormDialog>
</template> </template>
<style scoped> <style scoped>
.overtime-application-detail-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.overtime-application-detail-dialog__approve-btn {
--el-button-bg-color: #0f766e;
--el-button-border-color: #0f766e;
--el-button-hover-bg-color: #115e59;
--el-button-hover-border-color: #115e59;
--el-button-active-bg-color: #134e4a;
--el-button-active-border-color: #134e4a;
}
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
line-height: 1.7;
}
:deep(.overtime-application-detail-dialog__label),
:deep(.overtime-application-detail-dialog__label--compact) {
white-space: nowrap;
vertical-align: middle;
}
:deep(.overtime-application-detail-dialog__label) {
width: 96px;
min-width: 96px;
}
:deep(.overtime-application-detail-dialog__label--compact) {
width: 86px;
min-width: 86px;
}
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper) { :deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249); background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset; box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'; import { computed, nextTick, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict'; import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
import { import {
fetchCreateOvertimeApplication, fetchCreateOvertimeApplication,
@@ -85,8 +86,8 @@ const rules = computed(
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams { function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
return { return {
overtimeDate: '', overtimeDate: dayjs().format('YYYY-MM-DD'),
overtimeDuration: '', overtimeDuration: '0.5',
overtimeReason: '', overtimeReason: '',
overtimeContent: '', overtimeContent: '',
approverId: '' approverId: ''

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

@@ -7,16 +7,14 @@ export const overtimeApplicationStatusOptions: Array<{
}> = [ }> = [
{ label: '待审批', value: 'pending' }, { label: '待审批', value: 'pending' },
{ label: '已通过', value: 'approved' }, { label: '已通过', value: 'approved' },
{ label: '已退回', value: 'rejected' }, { label: '已退回', value: 'rejected' }
{ label: '已撤销', value: 'cancelled' }
]; ];
export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = { export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = {
submit: '提交', submit: '提交',
resubmit: '重新提交', resubmit: '重新提交',
approve: '通过', approve: '通过',
reject: '退回', reject: '退回'
cancel: '撤销'
}; };
export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) { export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) {

View File

@@ -1,89 +0,0 @@
<script setup lang="tsx">
import { ref, watch } from 'vue';
import { ElTag } from 'element-plus';
import { fetchGetOvertimeApplicationStatusLogs } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationActionLabel,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationStatusLogDialog' });
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const logs = ref<Api.OvertimeApplication.OvertimeApplicationStatusLog[]>([]);
async function loadLogs() {
if (!props.rowData?.id) {
logs.value = [];
return;
}
loading.value = true;
const { error, data } = await fetchGetOvertimeApplicationStatusLogs(props.rowData.id);
loading.value = false;
logs.value = error || !data ? [] : data;
}
function renderStatus(code?: string | null) {
if (!code) {
return '--';
}
return <ElTag type={resolveOvertimeApplicationStatusTagType(code)}>{getOvertimeApplicationStatusLabel(code)}</ElTag>;
}
watch(
() => visible.value,
value => {
if (value) {
loadLogs();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="状态日志"
width="920px"
:loading="loading"
:show-footer="false"
max-body-height="72vh"
>
<ElTable border :data="logs">
<ElTableColumn prop="createTime" label="操作时间" width="170">
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
</ElTableColumn>
<ElTableColumn prop="actionType" label="动作" width="110">
<template #default="{ row }">{{ getOvertimeApplicationActionLabel(row.actionType) }}</template>
</ElTableColumn>
<ElTableColumn prop="operatorName" label="操作人" width="120" show-overflow-tooltip />
<ElTableColumn prop="fromStatus" label="原状态" width="110" :formatter="row => renderStatus(row.fromStatus)" />
<ElTableColumn prop="toStatus" label="新状态" width="110" :formatter="row => renderStatus(row.toStatus)" />
<ElTableColumn prop="reason" label="原因/意见" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ formatEmptyText(row.reason) }}</template>
</ElTableColumn>
<ElTableColumn prop="overtimeDateSnapshot" label="加班日期" width="120">
<template #default="{ row }">{{ formatOvertimeDate(row.overtimeDateSnapshot) }}</template>
</ElTableColumn>
<ElTableColumn prop="overtimeDurationSnapshot" label="时长" width="90" show-overflow-tooltip />
</ElTable>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import WorkReportCreateDialog from './shared/components/create-dialog.vue';
import WorkReportPrototypePageDialog from './shared/components/prototype-page-dialog.vue';
import WorkReportTabs from './shared/components/tabs.vue';
import {
WORK_REPORT_PROJECT_OWNER_PERMISSION,
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType
} from './shared/types';
import WeeklyReportIndex from './weekly/index.vue';
import WeeklyReportApprovalRecordDialog from './weekly/modules/approval-record-dialog.vue';
import MonthlyReportIndex from './monthly/index.vue';
import MonthlyReportApprovalRecordDialog from './monthly/modules/approval-record-dialog.vue';
import ProjectReportIndex from './project/index.vue';
import ProjectReportApprovalRecordDialog from './project/modules/approval-record-dialog.vue';
defineOptions({ name: 'PersonalCenterWorkReport' });
type PageDialogMode = 'add' | 'edit' | 'detail';
type ReportListExpose = {
reload: (page?: number) => Promise<void>;
};
const { hasAuth } = useAuth();
const activeTab = ref<WorkReportType>('weekly');
const createVisible = ref(false);
const pageDialogVisible = ref(false);
const pageDialogMode = ref<PageDialogMode>('detail');
const approvalRecordVisible = ref(false);
const currentReportType = ref<WorkReportType>('weekly');
const currentRow = ref<WorkReportRow | null>(null);
const initialPeriod = ref<{
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
} | null>(null);
const initialProjectId = ref('');
const initialFlag = ref(1);
const projectOptions = ref<Api.WorkReport.Project.ProjectReportOwnerProjectOption[]>([]);
const weeklyRef = ref<ReportListExpose | null>(null);
const monthlyRef = ref<ReportListExpose | null>(null);
const projectRef = ref<ReportListExpose | null>(null);
const canShowProjectTab = computed(() => hasAuth(WORK_REPORT_PROJECT_OWNER_PERMISSION));
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
const projectOptionsLoaded = ref(false);
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
const tabs: Array<{ label: string; name: WorkReportType }> = [
{ label: WORK_REPORT_TYPE_LABEL.weekly, name: 'weekly' },
{ label: WORK_REPORT_TYPE_LABEL.monthly, name: 'monthly' }
];
if (canShowProjectTab.value) {
tabs.push({ label: WORK_REPORT_TYPE_LABEL.project, name: 'project' });
}
return tabs;
});
const currentApprovalRecordDialogComponent = computed(() => {
if (currentReportType.value === 'monthly') return MonthlyReportApprovalRecordDialog;
if (currentReportType.value === 'project') return ProjectReportApprovalRecordDialog;
return WeeklyReportApprovalRecordDialog;
});
function getListRef(reportType: WorkReportType) {
if (reportType === 'monthly') return monthlyRef.value;
if (reportType === 'project') return projectRef.value;
return weeklyRef.value;
}
async function loadProjectOptions() {
if (!canShowProjectTab.value) return;
const { error, data } = await fetchGetProjectReportOwnerProjectOptions();
projectOptions.value = error || !data ? [] : data;
projectOptionsLoaded.value = !error;
}
function openCreate(reportType: WorkReportType) {
currentReportType.value = reportType;
createVisible.value = true;
}
function handleCreateConfirm(
payload:
| { reportType: 'weekly' | 'monthly'; period: typeof initialPeriod.value extends infer T ? T : never }
| {
reportType: 'project';
projectId: string;
flag: number;
period: typeof initialPeriod.value extends infer T ? T : never;
}
) {
currentReportType.value = payload.reportType;
pageDialogMode.value = 'add';
currentRow.value = null;
initialPeriod.value = payload.period as typeof initialPeriod.value;
initialProjectId.value = 'projectId' in payload ? payload.projectId : '';
initialFlag.value = 'flag' in payload ? payload.flag : 1;
pageDialogVisible.value = true;
}
function openEdit(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
pageDialogMode.value = 'edit';
currentRow.value = row;
initialPeriod.value = null;
pageDialogVisible.value = true;
}
function openDetail(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
pageDialogMode.value = 'detail';
currentRow.value = row;
initialPeriod.value = null;
pageDialogVisible.value = true;
}
function openApprovalRecord(reportType: WorkReportType, row: WorkReportRow) {
currentReportType.value = reportType;
currentRow.value = row;
approvalRecordVisible.value = true;
}
function handleTabChange(tab: WorkReportType) {
activeTab.value = tab;
getListRef(tab)?.reload(1);
}
async function reloadReport(reportType = currentReportType.value) {
await getListRef(reportType)?.reload();
}
function handleSubmitted() {
pageDialogVisible.value = false;
reloadReport(currentReportType.value);
}
function closeFloatingPanels() {
createVisible.value = false;
pageDialogVisible.value = false;
approvalRecordVisible.value = false;
}
onMounted(async () => {
await loadProjectOptions();
});
onBeforeRouteLeave(() => {
closeFloatingPanels();
});
</script>
<template>
<div
class="work-report-page-shell min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[240px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<!-- 左侧报告类型导航 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<WorkReportTabs :active-tab="activeTab" :tabs="visibleTabs" @update:active-tab="handleTabChange" />
</div>
<!-- 右侧搜索区 + 列表区 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<WeeklyReportIndex
v-show="activeTab === 'weekly'"
ref="weeklyRef"
class="flex-1-hidden"
@create="openCreate('weekly')"
@edit="openEdit('weekly', $event)"
@detail="openDetail('weekly', $event)"
@approval-record="openApprovalRecord('weekly', $event)"
/>
<MonthlyReportIndex
v-show="activeTab === 'monthly'"
ref="monthlyRef"
class="flex-1-hidden"
@create="openCreate('monthly')"
@edit="openEdit('monthly', $event)"
@detail="openDetail('monthly', $event)"
@approval-record="openApprovalRecord('monthly', $event)"
/>
<ProjectReportIndex
v-if="canShowProjectTab"
v-show="activeTab === 'project'"
ref="projectRef"
class="flex-1-hidden"
:project-options="projectOptions"
:project-options-loaded="projectOptionsLoaded"
@create="openCreate('project')"
@edit="openEdit('project', $event)"
@detail="openDetail('project', $event)"
@approval-record="openApprovalRecord('project', $event)"
/>
</div>
<WorkReportCreateDialog
v-model:visible="createVisible"
:default-report-type="currentReportType"
:project-visible="canShowProjectTab"
:project-options="projectOptions"
@confirm="handleCreateConfirm"
/>
<WorkReportPrototypePageDialog
v-model:visible="pageDialogVisible"
:mode="pageDialogMode"
scene="fill"
:report-type="currentReportType"
:row-data="currentRow"
:initial-period="initialPeriod"
:initial-project-id="initialProjectId"
:initial-flag="initialFlag"
@submitted="handleSubmitted"
/>
<component
:is="currentApprovalRecordDialogComponent"
v-model:visible="approvalRecordVisible"
:row-data="currentRow"
/>
</div>
</template>
<style scoped>
.work-report-page-shell {
height: 100%;
}
</style>

View File

@@ -0,0 +1,345 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteMonthlyReport,
fetchExportMonthlyReportContent,
fetchGetMonthlyReportPage,
fetchSubmitMonthlyReport
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createMonthlySearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import MonthlyReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
defineOptions({ name: 'MonthlyWorkReportIndex' });
const emit = defineEmits<{
(e: 'create'): void;
(e: 'edit', row: WorkReportRow): void;
(e: 'detail', row: WorkReportRow): void;
(e: 'approvalRecord', row: WorkReportRow): void;
}>();
const { hasAuth } = useAuth();
const exporting = ref(false);
const selectedRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const searchParams = reactive(createMonthlySearchParams());
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
};
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
Api.WorkReport.Monthly.MonthlyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetMonthlyReportPage(searchParams),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'periodLabel', label: '月份', minWidth: 80, formatter: row => formatPeriod(row) },
{
prop: 'reporterDeptName',
label: '部门',
minWidth: 80,
showOverflowTooltip: true,
formatter: row => row.reporterDeptName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 80, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'statusCode',
label: '状态',
minWidth: 80,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'primary',
icon: ACTION_ICON_MAP.edit,
onClick: () => emit('edit', row)
});
actions.push({
key: 'submit',
label: row.statusCode === 'draft' ? '提交' : '重新提交',
buttonType: 'success',
icon: ACTION_ICON_MAP.submit,
onClick: () => handleSubmitReport(row)
});
}
if (row.statusCode === 'draft' && hasAuth('project:work-report:delete')) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
onClick: () => handleDelete(row)
});
}
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => emit('approvalRecord', row)
});
}
return actions;
}
async function reload(page?: number) {
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, createMonthlySearchParams(), { pageSize });
reload(1);
}
function handleSearch() {
reload(1);
}
async function handleSubmitReport(row: Api.WorkReport.Monthly.MonthlyReport) {
try {
await ElMessageBox.confirm('确认提交该报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: row.statusCode === 'draft' ? '确认提交' : '确认重新提交',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchSubmitMonthlyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已提交');
await reload();
}
async function handleDelete(row: Api.WorkReport.Monthly.MonthlyReport) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchDeleteMonthlyReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已删除');
await reload();
}
function handleSelectionChange(rows: Api.WorkReport.Monthly.MonthlyReport[]) {
selectedRows.value = rows;
}
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
}
async function exportReportContent(
params: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>,
reportCount: number
) {
exporting.value = true;
const result = await fetchExportMonthlyReportContent(params);
exporting.value = false;
if (result.error || !result.data) return;
const fallbackName = createWorkReportContentExportFallbackName('monthly', reportCount);
downloadBlob(result.data, resolveExportFilename(result, fallbackName));
}
async function handleExportSelected() {
if (!selectedRows.value.length) {
window.$message?.warning('请选择要导出的报告');
return;
}
await exportReportContent(
{
exportAll: false,
ids: selectedRows.value.map(item => item.id)
},
selectedRows.value.length
);
}
async function handleExportAll() {
const total = table.mobilePagination.value.total || 0;
if (!total) {
window.$message?.warning('暂无可导出的报告');
return;
}
await exportReportContent(
{
...createExportSearchParams(),
exportAll: true,
ids: []
},
total
);
}
async function handleExportCommand(command: 'selected' | 'all') {
if (command === 'selected') {
await handleExportSelected();
return;
}
await handleExportAll();
}
defineExpose({ reload });
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<MonthlyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.monthly }}</p>
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="table.columnChecks.value"
:loading="table.loading.value"
@refresh="reload()"
>
<template #default>
<ElDropdown v-auth="'project:work-report:export'" trigger="click" @command="handleExportCommand">
<ElButton plain :loading="exporting">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
导出选中
</ElDropdownItem>
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
v-loading="table.loading.value"
height="100%"
border
row-key="id"
:data="table.data.value"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" />
<template v-for="col in table.columns.value" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="table.mobilePagination.value.total"
layout="total,prev,pager,next,sizes"
v-bind="table.mobilePagination.value"
@current-change="table.mobilePagination.value['current-change']"
@size-change="table.mobilePagination.value['size-change']"
/>
</div>
</ElCard>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'MonthlyReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="monthly" :row-data="rowData" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportDetailDialog from '../../shared/components/detail-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'MonthlyReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="monthly" :row-data="rowData" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'MonthlyReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Monthly.MonthlyReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="monthly"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>

View File

@@ -0,0 +1,363 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteProjectReport,
fetchExportProjectReportContent,
fetchGetProjectReportPage,
fetchSubmitProjectReport
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
createProjectSearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getProjectReportFlagLabel,
getWorkReportStatusLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import ProjectReportSearch from './modules/search-panel.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFileDocumentCheckOutline from '~icons/mdi/file-document-check-outline';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiSendOutline from '~icons/mdi/send-outline';
defineOptions({ name: 'ProjectWorkReportIndex' });
const props = defineProps<{
projectOptions: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
projectOptionsLoaded: boolean;
}>();
const emit = defineEmits<{
(e: 'create'): void;
(e: 'edit', row: WorkReportRow): void;
(e: 'detail', row: WorkReportRow): void;
(e: 'approvalRecord', row: WorkReportRow): void;
}>();
const { hasAuth } = useAuth();
const exporting = ref(false);
const selectedRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
const searchParams = reactive(createProjectSearchParams());
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline)
};
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
Api.WorkReport.Project.ProjectReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () => fetchGetProjectReportPage(searchParams),
transform: response => transformWorkReportPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
// { prop: 'flag', label: '半月', width: 90, formatter: row => getProjectReportFlagLabel(row.flag) },
{ prop: 'projectOwnerName', label: '项目负责人', minWidth: 80 },
{
prop: 'technicalOwnerName',
label: '技术负责人',
minWidth: 80,
formatter: row => row.technicalOwnerName || '--'
},
{ prop: 'supervisorName', label: '直属上级', minWidth: 80 },
{ prop: 'totalWorkHours', label: '总工时', minWidth: 60, formatter: row => formatEmptyText(row.totalWorkHours) },
{
prop: 'statusCode',
label: '状态',
minWidth: 60,
align: 'center',
formatter: row => (
<ElTag type={resolveWorkReportStatusTagType(row.statusCode)}>
{getWorkReportStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'submitTime', label: '提交时间', minWidth: 100, formatter: row => formatDateTime(row.submitTime) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 100, formatter: row => formatDateTime(row.approvalTime) },
{
prop: 'operate',
label: '操作',
width: 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => emit('detail', row)
}
];
if (['draft', 'rejected'].includes(row.statusCode) && row.allowEdit && hasAuth('project:work-report:update')) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'primary',
icon: ACTION_ICON_MAP.edit,
onClick: () => emit('edit', row)
});
actions.push({
key: 'submit',
label: row.statusCode === 'draft' ? '提交' : '重新提交',
buttonType: 'success',
icon: ACTION_ICON_MAP.submit,
onClick: () => handleSubmitReport(row)
});
}
if (row.statusCode === 'draft' && hasAuth('project:work-report:delete')) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
onClick: () => handleDelete(row)
});
}
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => emit('approvalRecord', row)
});
}
return actions;
}
async function reload(page?: number) {
await table.getDataByPage(page ?? searchParams.pageNo ?? 1);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, createProjectSearchParams(), { pageSize });
reload(1);
}
function handleSearch() {
reload(1);
}
async function handleSubmitReport(row: Api.WorkReport.Project.ProjectReport) {
try {
await ElMessageBox.confirm('确认提交该报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: row.statusCode === 'draft' ? '确认提交' : '确认重新提交',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchSubmitProjectReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已提交');
await reload();
}
async function handleDelete(row: Api.WorkReport.Project.ProjectReport) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
});
} catch {
return;
}
const result = await fetchDeleteProjectReport(row.id);
if (result.error) return;
window.$message?.success('工作报告已删除');
await reload();
}
function handleSelectionChange(rows: Api.WorkReport.Project.ProjectReport[]) {
selectedRows.value = rows;
}
function createExportSearchParams() {
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return params;
}
async function exportReportContent(
params: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>,
reportCount: number
) {
exporting.value = true;
const result = await fetchExportProjectReportContent(params);
exporting.value = false;
if (result.error || !result.data) return;
const fallbackName = createWorkReportContentExportFallbackName('project', reportCount);
downloadBlob(result.data, resolveExportFilename(result, fallbackName));
}
async function handleExportSelected() {
if (!selectedRows.value.length) {
window.$message?.warning('请选择要导出的报告');
return;
}
await exportReportContent(
{
exportAll: false,
ids: selectedRows.value.map(item => item.id)
},
selectedRows.value.length
);
}
async function handleExportAll() {
const total = table.mobilePagination.value.total || 0;
if (!total) {
window.$message?.warning('暂无可导出的报告');
return;
}
await exportReportContent(
{
...createExportSearchParams(),
exportAll: true,
ids: []
},
total
);
}
async function handleExportCommand(command: 'selected' | 'all') {
if (command === 'selected') {
await handleExportSelected();
return;
}
await handleExportAll();
}
defineExpose({ reload });
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<!-- 项目选项加载失败时的提示 -->
<ElAlert v-if="!projectOptionsLoaded" type="warning" :closable="false" show-icon>
项目数据加载失败部分功能可能不可用请刷新页面重试
</ElAlert>
<ProjectReportSearch
v-model:model="searchParams"
:project-options="projectOptions"
@reset="resetSearchParams"
@search="handleSearch"
/>
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p class="text-16px font-600">{{ WORK_REPORT_TYPE_LABEL.project }}</p>
<ElTag effect="plain">{{ table.mobilePagination.value.total || 0 }}</ElTag>
</div>
<TableHeaderOperation
v-model:columns="table.columnChecks.value"
:loading="table.loading.value"
@refresh="reload()"
>
<template #default>
<ElDropdown v-auth="'project:work-report:export'" trigger="click" @command="handleExportCommand">
<ElButton plain :loading="exporting">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
导出选中
</ElDropdownItem>
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton v-auth="'project:work-report:create'" plain type="primary" @click="emit('create')">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
v-loading="table.loading.value"
height="100%"
border
row-key="id"
:data="table.data.value"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" />
<template v-for="col in table.columns.value" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="table.mobilePagination.value.total"
layout="total,prev,pager,next,sizes"
v-bind="table.mobilePagination.value"
@current-change="table.mobilePagination.value['current-change']"
@size-change="table.mobilePagination.value['size-change']"
/>
</div>
</ElCard>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'ProjectReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="project" :row-data="rowData" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportDetailDialog from '../../shared/components/detail-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'ProjectReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="project" :row-data="rowData" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import SharedWorkReportSearch from '../../shared/components/search-panel.vue';
defineOptions({ name: 'ProjectReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Project.ProjectReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="project"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>

View File

@@ -0,0 +1,403 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import dayjs from 'dayjs';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportActionDialog' });
type ActionType = 'approve' | 'reject';
type ApprovalConclusion = 'approve' | 'reject';
const visible = defineModel<boolean>('visible', { default: false });
const props = withDefaults(
defineProps<{
reportType: WorkReportType;
actionType: ActionType;
initialMonthlyApproveData?: Partial<Api.WorkReport.Monthly.MonthlyReportApproveParams> | null;
loading?: boolean;
}>(),
{
initialMonthlyApproveData: null,
loading: false
}
);
const emit = defineEmits<{
(
e: 'submit',
payload: Api.WorkReport.Common.StatusActionParams | Api.WorkReport.Monthly.MonthlyReportApproveParams,
actionType?: ActionType
): void;
}>();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const reasonModel = reactive<Api.WorkReport.Common.StatusActionParams>({
reason: ''
});
const commonApprovalModel = reactive<{
conclusion: ApprovalConclusion | '';
opinion: string;
}>({
conclusion: 'approve',
opinion: ''
});
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
const isMonthlyApprove = computed(() => props.reportType === 'monthly' && props.actionType === 'approve');
const isCommonApprove = computed(() => props.reportType !== 'monthly' && props.actionType === 'approve');
const title = computed(() => {
if (isCommonApprove.value) {
return `审批${WORK_REPORT_TYPE_LABEL[props.reportType]}`;
}
const actionLabel = props.actionType === 'approve' ? '审批通过' : '退回';
return `${actionLabel}${WORK_REPORT_TYPE_LABEL[props.reportType]}`;
});
const preset = computed(() => (isMonthlyApprove.value ? 'lg' : 'sm'));
const rejectOpinionRequired = computed(() => isCommonApprove.value && commonApprovalModel.conclusion === 'reject');
const opinionLabel = computed(() => (rejectOpinionRequired.value ? '退回原因' : '审批意见'));
const opinionPlaceholder = computed(() =>
rejectOpinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'
);
const confirmText = computed(() => {
if (isCommonApprove.value) return '确认提交';
if (props.actionType === 'approve') return '通过';
return '退回';
});
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion);
const commonRules = computed(() => ({
opinion: rejectOpinionRequired.value
? [
createRequiredRule(`请输入${opinionLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${opinionLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
watch(visible, isVisible => {
if (!isVisible) return;
reasonModel.reason = '';
Object.assign(commonApprovalModel, {
conclusion: 'approve',
opinion: ''
});
Object.assign(monthlyModel, {
reason: '',
meetingDate: dayjs().format('YYYY-MM-DD'),
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: dayjs().format('YYYY-MM-DD'),
supervisorSignName: '',
supervisorSignedDate: dayjs().format('YYYY-MM-DD')
});
if (props.initialMonthlyApproveData) {
Object.assign(monthlyModel, props.initialMonthlyApproveData);
}
});
watch(
() => visible.value,
async isVisible => {
if (!isVisible || !isCommonApprove.value) return;
await nextTick();
formRef.value?.clearValidate();
}
);
watch(rejectOpinionRequired, async () => {
if (!visible.value || !isCommonApprove.value) return;
await nextTick();
formRef.value?.clearValidate('opinion');
});
async function handleSubmit() {
if (isCommonApprove.value) {
if (!commonApprovalModel.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
await validate();
emit(
'submit',
{
reason: commonApprovalModel.opinion.trim() || (commonApprovalModel.conclusion === 'approve' ? '通过' : '退回')
},
commonApprovalModel.conclusion
);
return;
}
emit('submit', isMonthlyApprove.value ? { ...monthlyModel } : { ...reasonModel });
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
:preset="preset"
:confirm-loading="loading"
:confirm-disabled="confirmDisabled"
:confirm-text="confirmText"
max-body-height="76vh"
@confirm="handleSubmit"
>
<template v-if="isCommonApprove">
<div class="audit-form">
<div class="audit-field">
<label>审批结论</label>
<div class="audit-conclusion">
<button
type="button"
class="conclusion-btn"
:class="{
active: commonApprovalModel.conclusion === 'approve',
pass: commonApprovalModel.conclusion === 'approve'
}"
@click="commonApprovalModel.conclusion = 'approve'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path
d="M5 8.5L7 10.5L11 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
通过
</button>
<button
type="button"
class="conclusion-btn"
:class="{
active: commonApprovalModel.conclusion === 'reject',
reject: commonApprovalModel.conclusion === 'reject'
}"
@click="commonApprovalModel.conclusion = 'reject'"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
退回
</button>
</div>
</div>
<ElForm
ref="formRef"
:model="commonApprovalModel"
:rules="commonRules"
label-position="top"
:validate-on-rule-change="false"
>
<ElFormItem :label="opinionLabel" prop="opinion">
<ElInput
v-model="commonApprovalModel.opinion"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="opinionPlaceholder"
/>
</ElFormItem>
</ElForm>
</div>
</template>
<template v-else-if="isMonthlyApprove">
<BusinessFormSection title="当期工作反馈">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="面谈时间">
<ElDatePicker v-model="monthlyModel.meetingDate" class="w-full" type="date" value-format="YYYY-MM-DD" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="绩效考核结果">
<ElInput v-model="monthlyModel.performanceResult" placeholder="请输入绩效结果" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="审批意见">
<ElInput v-model="monthlyModel.reason" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="优势与不足">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="优势描述">
<ElInput v-model="monthlyModel.strengthDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优势行为事例">
<ElInput v-model="monthlyModel.strengthExample" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="劣势描述">
<ElInput v-model="monthlyModel.weaknessDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="劣势行为事例">
<ElInput v-model="monthlyModel.weaknessExample" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="改进建议">
<ElInput v-model="monthlyModel.improvementSuggestion" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="签字区">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="被考核人签名">
<ElInput v-model="monthlyModel.employeeSignName" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="被考核人签字日期">
<ElDatePicker
v-model="monthlyModel.employeeSignedDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="上级签名">
<ElInput v-model="monthlyModel.supervisorSignName" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="上级签字日期">
<ElDatePicker
v-model="monthlyModel.supervisorSignedDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</template>
<ElForm v-else label-position="top">
<ElFormItem :label="actionType === 'approve' ? '审批意见' : '原因'">
<ElInput v-model="reasonModel.reason" type="textarea" :rows="5" placeholder="请输入原因或意见" />
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.audit-form {
display: grid;
gap: 18px;
}
.audit-field {
display: grid;
gap: 8px;
}
.audit-field label {
color: #475467;
font-size: 13px;
font-weight: 800;
}
.audit-conclusion {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.conclusion-btn {
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
color: #475467;
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: all 0.18s ease;
}
.conclusion-btn:hover {
border-color: #0f766e;
color: #0f766e;
}
.conclusion-btn.active.pass {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
}
.conclusion-btn.active.reject {
border-color: #dc2626;
background: #fef2f2;
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
/* eslint-disable no-void */
import { computed, ref, watch } from 'vue';
import {
fetchGetMonthlyReportApprovalRecords,
fetchGetProjectReportApprovalRecords,
fetchGetWeeklyReportApprovalRecords
} from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatDate,
formatDateTime,
getWorkReportStatusLabel
} from '../types';
/** 格式化文本,空值显示 -- */
function formatTextOrDash(value?: string | number | null) {
if (value === null || value === undefined || value === '') {
return '--';
}
return String(value);
}
defineOptions({ name: 'WorkReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
reportType: WorkReportType;
rowData?: WorkReportRow | null;
}>();
const loading = ref(false);
const records = ref<
Array<Api.WorkReport.Common.WorkReportApprovalRecord | Api.WorkReport.Monthly.MonthlyReportApprovalRecord>
>([]);
const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}审批记录`);
const monthlyRecords = computed(() => records.value as Api.WorkReport.Monthly.MonthlyReportApprovalRecord[]);
watch(
[visible, () => props.rowData?.id, () => props.reportType],
([isVisible, currentId]) => {
if (!isVisible) return;
// visible 为 true首次打开、换行、换报告类型时都重新加载记录
if (currentId) {
loadRecords();
}
},
{ immediate: true }
);
async function loadRecords() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportApprovalRecords(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportApprovalRecords(props.rowData.id);
} else {
result = await fetchGetProjectReportApprovalRecords(props.rowData.id);
}
loading.value = false;
records.value = !result.error && result.data ? result.data : [];
}
</script>
<template>
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
<ElTable v-if="reportType !== 'monthly'" border :data="records">
<ElTableColumn prop="approvalRound" label="轮次" width="80" />
<ElTableColumn label="结论" width="100">
<template #default="{ row }">
{{ getWorkReportStatusLabel(row.conclusion) }}
</template>
</ElTableColumn>
<ElTableColumn prop="opinion" label="审批意见" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="auditorName" label="审批人" width="120" />
<ElTableColumn label="审批时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</ElTableColumn>
</ElTable>
<div v-else class="work-report-approval-records">
<ElCard v-for="item in monthlyRecords" :key="item.id">
<template #header>
<div class="flex items-center justify-between gap-12px">
<span> {{ item.approvalRound }} · {{ getWorkReportStatusLabel(item.conclusion) }}</span>
<span class="text-12px text-#64748b">{{ item.auditorName }} · {{ formatDateTime(item.createTime) }}</span>
</div>
</template>
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="审批意见" :span="2">{{ formatTextOrDash(item.opinion) }}</ElDescriptionsItem>
<ElDescriptionsItem label="面谈时间">{{ formatDate(item.meetingDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="绩效结果">{{ formatTextOrDash(item.performanceResult) }}</ElDescriptionsItem>
<ElDescriptionsItem label="优势描述">{{ formatTextOrDash(item.strengthDesc) }}</ElDescriptionsItem>
<ElDescriptionsItem label="优势事例">{{ formatTextOrDash(item.strengthExample) }}</ElDescriptionsItem>
<ElDescriptionsItem label="劣势描述">{{ formatTextOrDash(item.weaknessDesc) }}</ElDescriptionsItem>
<ElDescriptionsItem label="劣势事例">{{ formatTextOrDash(item.weaknessExample) }}</ElDescriptionsItem>
<ElDescriptionsItem label="改进建议" :span="2">
{{ formatTextOrDash(item.improvementSuggestion) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="被考核人签字">
{{ formatTextOrDash(item.employeeSignName) }} / {{ formatDate(item.employeeSignedDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="上级签字">
{{ formatTextOrDash(item.supervisorSignName) }} / {{ formatDate(item.supervisorSignedDate) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-approval-records {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,534 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Calendar } from '@element-plus/icons-vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
type WorkReportPeriodOption,
buildMonthlyPeriodFromMonth,
buildProjectPeriodFromMonth,
buildWeeklyPeriodFromDate,
formatPeriodDisplayLabel,
getReportTypePeriodOptions
} from '../utils';
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportCreateDialog' });
interface Props {
defaultReportType?: WorkReportType;
projectVisible?: boolean;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}
const props = withDefaults(defineProps<Props>(), {
defaultReportType: 'weekly',
projectVisible: false,
projectOptions: () => []
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(
e: 'confirm',
payload:
| { reportType: 'weekly' | 'monthly'; period: WorkReportPeriodOption['period'] }
| {
reportType: 'project';
projectId: string;
flag: number;
period: WorkReportPeriodOption['period'];
}
): void;
}>();
const selectedPeriodKey = ref('');
const selectedProjectId = ref('');
const customWeekDate = ref('');
const customMonth = ref('');
const customProjectMonth = ref('');
const customProjectFlag = ref(1);
const selectedReportType = computed<WorkReportType>(() => {
if (props.defaultReportType === 'project' && !props.projectVisible) return 'weekly';
return props.defaultReportType;
});
const periodOptionMap = computed(() => getReportTypePeriodOptions());
const activePeriodOptions = computed(() => periodOptionMap.value[selectedReportType.value]);
const dialogTitle = computed(() => `新增${WORK_REPORT_TYPE_LABEL[selectedReportType.value]}`);
const projectHalfOptions = [
{ label: '上半月', value: 1 },
{ label: '下半月', value: 2 }
];
const defaultCustomMonth = computed(() => {
const period = activePeriodOptions.value[0]?.period;
return period?.periodStartDate.slice(0, 7) || '';
});
const customPeriod = computed<WorkReportPeriodOption['period'] | null>(() => {
if (selectedPeriodKey.value !== 'custom') return null;
if (selectedReportType.value === 'weekly') {
if (!customWeekDate.value) return null;
return buildWeeklyPeriodFromDate(customWeekDate.value);
}
if (selectedReportType.value === 'monthly') {
if (!customMonth.value) return null;
return buildMonthlyPeriodFromMonth(customMonth.value);
}
if (!customProjectMonth.value) return null;
return buildProjectPeriodFromMonth(customProjectMonth.value, customProjectFlag.value);
});
const selectedPeriod = computed(
() => activePeriodOptions.value.find(item => item.key === selectedPeriodKey.value) ?? activePeriodOptions.value[0]
);
const selectedPeriodValue = computed(() =>
selectedPeriodKey.value === 'custom' ? customPeriod.value : selectedPeriod.value?.period
);
const customPeriodPreviewLabel = computed(() =>
customPeriod.value ? formatPeriodDisplayLabel(customPeriod.value.periodLabel) : ''
);
const confirmDisabled = computed(() => {
if (!selectedPeriodValue.value) return true;
if (selectedReportType.value === 'project' && !selectedProjectId.value) return true;
return false;
});
watch(
selectedReportType,
type => {
selectedPeriodKey.value = periodOptionMap.value[type][0]?.key || '';
if (type === 'project' && !selectedProjectId.value) {
selectedProjectId.value = props.projectOptions[0]?.id || '';
}
},
{ immediate: true }
);
watch(visible, isVisible => {
if (!isVisible) return;
selectedProjectId.value = props.projectOptions[0]?.id || '';
selectedPeriodKey.value = periodOptionMap.value[selectedReportType.value][0]?.key || '';
customWeekDate.value = activePeriodOptions.value[0]?.period.periodStartDate || '';
customMonth.value = defaultCustomMonth.value;
customProjectMonth.value = defaultCustomMonth.value;
customProjectFlag.value = activePeriodOptions.value[0]?.flag || 1;
});
function handleConfirm() {
const period = selectedPeriodValue.value;
if (!period) return;
if (selectedReportType.value === 'project') {
emit('confirm', {
reportType: 'project',
projectId: selectedProjectId.value,
flag: selectedPeriodKey.value === 'custom' ? customProjectFlag.value : selectedPeriod.value.flag || 1,
period
});
} else {
emit('confirm', {
reportType: selectedReportType.value,
period
});
}
visible.value = false;
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
class="work-report-create-dialog"
preset="md"
confirm-text="确认新增"
append-to-body
:close-on-click-modal="false"
@confirm="handleConfirm"
>
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
<label class="work-report-create-dialog__label">项目</label>
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
<ElOption
v-for="item in props.projectOptions"
:key="item.id"
:label="item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName"
:value="item.id"
/>
</ElSelect>
</div>
<div class="work-report-create-dialog__section">
<div class="work-report-create-dialog__grid is-period">
<button
v-for="item in activePeriodOptions"
:key="item.key"
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === item.key }"
@click="selectedPeriodKey = item.key"
>
<div class="work-report-create-dialog__choice-title">{{ item.label }}</div>
<div class="work-report-create-dialog__choice-desc">{{ item.description }}</div>
</button>
<button
type="button"
class="work-report-create-dialog__choice"
:class="{ 'is-active': selectedPeriodKey === 'custom' }"
@click="selectedPeriodKey = 'custom'"
>
<div class="work-report-create-dialog__choice-title">自定义周期</div>
<div class="work-report-create-dialog__choice-desc">
{{
selectedReportType === 'weekly'
? '选择某一周作为周报周期。'
: selectedReportType === 'monthly'
? '选择某一月作为月报周期。'
: '选择某个月的上半月或下半月。'
}}
</div>
</button>
</div>
<div v-if="selectedPeriodKey === 'custom'" class="work-report-create-dialog__custom-period">
<div v-if="selectedReportType === 'weekly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">周报周期</label>
<ElDatePicker
v-model="customWeekDate"
type="date"
format="YYYY[年第]ww[周]"
value-format="YYYY-MM-DD"
popper-class="work-report-create-date-popper"
placeholder="请选择周报周期"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else-if="selectedReportType === 'monthly'" class="work-report-create-dialog__custom-row">
<div class="work-report-create-dialog__field work-report-create-dialog__field--inline">
<label class="work-report-create-dialog__label">月报周期</label>
<ElDatePicker
v-model="customMonth"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
{{ customPeriodPreviewLabel }}
</div>
</div>
</div>
<div v-else class="work-report-create-dialog__custom-project">
<div class="work-report-create-dialog__custom-project-grid">
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择月份</div>
<ElDatePicker
v-model="customProjectMonth"
class="w-full"
type="month"
value-format="YYYY-MM"
popper-class="work-report-create-date-popper"
placeholder="请选择月份"
/>
</div>
<div class="work-report-create-dialog__custom-project-item">
<div class="work-report-create-dialog__custom-project-item-label">选择半月</div>
<ElSegmented
v-model="customProjectFlag"
:options="projectHalfOptions"
class="work-report-create-dialog__half-segmented"
/>
</div>
</div>
<div v-if="customPeriodPreviewLabel" class="work-report-create-dialog__period-preview">
<ElIcon class="work-report-create-dialog__period-preview-icon"><Calendar /></ElIcon>
<span class="work-report-create-dialog__period-preview-text">已选周期</span>
<span class="work-report-create-dialog__period-preview-value">{{ customPeriodPreviewLabel }}</span>
</div>
</div>
</div>
</div>
<template #footer="{ close }">
<div class="work-report-create-dialog__footer">
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确认新增</ElButton>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-create-dialog__header {
padding: 0 0 14px;
}
.work-report-create-dialog__title {
margin: 0;
font-size: 18px;
font-weight: 900;
}
.work-report-create-dialog__subtitle {
margin-top: 5px;
color: #667085;
font-size: 12px;
}
.work-report-create-dialog__section + .work-report-create-dialog__section {
margin-top: 18px;
}
.work-report-create-dialog__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.work-report-create-dialog__grid.is-period {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.work-report-create-dialog__choice {
padding: 16px;
border: 2px solid #e5edf1;
border-radius: 16px;
background: #fbfdfe;
text-align: left;
cursor: pointer;
transition:
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease;
}
.work-report-create-dialog__choice:hover {
border-color: rgba(15, 118, 110, 0.28);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.work-report-create-dialog__choice.is-active {
border-color: #0f766e;
background: #ecfdf5;
}
.work-report-create-dialog__choice-title {
font-weight: 900;
color: #14213d;
}
.work-report-create-dialog__choice-desc {
margin-top: 7px;
color: #667085;
font-size: 12px;
line-height: 1.5;
}
.work-report-create-dialog__project-select {
margin: 4px 0 18px;
display: grid;
gap: 6px;
}
.work-report-create-dialog__field {
display: grid;
gap: 6px;
}
/** 行内字段label 和控件在同一行,绿色 label 紧贴日期选择器右边 */
.work-report-create-dialog__field--inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.work-report-create-dialog__field--inline .work-report-create-dialog__label {
flex-shrink: 0;
white-space: nowrap;
}
.work-report-create-dialog__field--inline :deep(.el-date-editor) {
width: auto;
min-width: 160px;
max-width: 240px;
}
.work-report-create-dialog__label {
color: #667085;
font-size: 12px;
font-weight: 800;
}
.work-report-create-dialog__custom-period {
margin-top: 14px;
padding: 16px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 14px;
background: linear-gradient(180deg, #f8fffd 0%, #ffffff 100%);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.work-report-create-dialog__custom-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.work-report-create-dialog__custom-row > .work-report-create-dialog__field--inline {
flex: 1;
min-width: 0;
}
.work-report-create-dialog__custom-project {
display: grid;
gap: 14px;
}
.work-report-create-dialog__custom-project-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}
.work-report-create-dialog__custom-project-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e5edf1;
border-radius: 10px;
background: #fff;
transition: border-color 0.18s ease;
}
.work-report-create-dialog__custom-project-item:hover {
border-color: rgba(15, 118, 110, 0.4);
}
.work-report-create-dialog__custom-project-item-label {
color: #475467;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.2px;
}
.work-report-create-dialog__custom-project-item :deep(.el-date-editor) {
width: 100%;
}
.work-report-create-dialog__half-segmented {
width: 100%;
display: flex;
}
.work-report-create-dialog__half-segmented :deep(.el-segmented__group) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
gap: 0;
}
.work-report-create-dialog__half-segmented :deep(.el-segmented__item) {
flex: 1;
min-width: 0;
justify-content: center;
}
.work-report-create-dialog__period-preview {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 14px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
width: fit-content;
}
.work-report-create-dialog__period-preview-icon {
font-size: 14px;
color: #0f766e;
}
.work-report-create-dialog__period-preview-text {
color: #475467;
font-weight: 600;
}
.work-report-create-dialog__period-preview-value {
color: #0f766e;
font-weight: 800;
}
.work-report-create-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (width <= 900px) {
.work-report-create-dialog__grid,
.work-report-create-dialog__grid.is-period {
grid-template-columns: 1fr;
}
.work-report-create-dialog__custom-row,
.work-report-create-dialog__custom-project-grid {
flex-direction: column;
grid-template-columns: 1fr;
}
.work-report-create-dialog__field--inline {
flex-wrap: wrap;
}
.work-report-create-dialog__field--inline :deep(.el-date-editor) {
max-width: 100%;
flex: 1;
}
.work-report-create-dialog__period-preview {
justify-content: center;
width: 100%;
}
}
:global(.work-report-create-date-popper) {
border-radius: 12px;
overflow: hidden;
}
:global(.work-report-create-date-popper .el-picker-panel__body-wrapper) {
background: #fff;
}
:global(.work-report-create-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text),
:global(.work-report-create-date-popper .el-month-table td.current:not(.disabled) .cell) {
background-color: #0f766e;
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetMonthlyReportDetail, fetchGetProjectReportDetail, fetchGetWeeklyReportDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatDate,
formatEmptyText,
formatPeriod,
formatPeriodDateRange,
formatWeeklyPeriodLabel,
getProjectReportFlagLabel,
getWorkReportStatusLabel
} from '../types';
defineOptions({ name: 'WorkReportDetailDialog' });
const visible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
reportType: WorkReportType;
rowData?: WorkReportRow | null;
}>();
const loading = ref(false);
const detail = ref<WorkReportRow | null>(null);
const title = computed(() => `${WORK_REPORT_TYPE_LABEL[props.reportType]}详情`);
const weeklyDetail = computed(() =>
props.reportType === 'weekly' ? (detail.value as Api.WorkReport.Weekly.WeeklyReport | null) : null
);
const periodText = computed(() => {
if (!detail.value) return '--';
return props.reportType === 'weekly' ? formatWeeklyPeriodLabel(detail.value) : formatPeriod(detail.value);
});
const periodTooltip = computed(() => {
if (!detail.value || props.reportType !== 'weekly') return '';
return formatPeriodDateRange(detail.value);
});
watch(visible, isVisible => {
if (isVisible) loadDetail();
});
async function loadDetail() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(props.rowData.id);
} else {
result = await fetchGetProjectReportDetail(props.rowData.id);
}
loading.value = false;
if (!result.error && result.data) {
detail.value = result.data;
}
}
function getProjectDetail() {
return detail.value as Api.WorkReport.Project.ProjectReport | null;
}
function getPersonalDetail() {
return detail.value as Api.WorkReport.Weekly.WeeklyReport | Api.WorkReport.Monthly.MonthlyReport | null;
}
</script>
<template>
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
<div v-if="detail" class="work-report-detail">
<BusinessFormSection title="基础信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="报告周期">
<ElTooltip :disabled="!periodTooltip || periodTooltip === '--'" :content="periodTooltip" placement="top">
<span>{{ periodText }}</span>
</ElTooltip>
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
{{ getWorkReportStatusLabel(detail.statusCode, detail.statusName) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="直属上级">{{ detail.supervisorName }}</ElDescriptionsItem>
<ElDescriptionsItem label="开始日期">{{ formatDate(detail.periodStartDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="结束日期">{{ formatDate(detail.periodEndDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="总工时">{{ formatEmptyText(detail.totalWorkHours) }}</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ formatEmptyText(detail.submitTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审批时间">{{ formatEmptyText(detail.approvalTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审批意见">{{ formatEmptyText(detail.approvalComment) }}</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<template v-if="reportType === 'project'">
<BusinessFormSection title="项目信息">
<ElDescriptions :column="2" border size="small">
<ElDescriptionsItem label="项目名称">{{ getProjectDetail()?.projectName }}</ElDescriptionsItem>
<ElDescriptionsItem label="半月周期">
{{ getProjectReportFlagLabel(getProjectDetail()?.flag) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目负责人">{{ getProjectDetail()?.projectOwnerName }}</ElDescriptionsItem>
<ElDescriptionsItem label="技术负责人">
{{ formatEmptyText(getProjectDetail()?.technicalOwnerName) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目状态" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectStatusDesc) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="整体计划进度" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectProgressPlan) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="要点描述" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectKeyPoints) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目问题" :span="2">
{{ formatEmptyText(getProjectDetail()?.projectProblems) }}
</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<BusinessFormSection title="本期工作内容">
<ElTable border :data="getProjectDetail()?.currentItems || []">
<ElTableColumn prop="itemTitle" label="工作内容" min-width="180" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="priorityCode" label="优先级" width="120" />
<ElTableColumn prop="progressRate" label="进度" width="100" />
</ElTable>
</BusinessFormSection>
<BusinessFormSection title="下期计划工作内容">
<ElTable border :data="getProjectDetail()?.nextItems || []">
<ElTableColumn prop="itemTitle" label="工作内容" min-width="180" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="priorityCode" label="优先级" width="120" />
<ElTableColumn prop="progressRate" label="进度" width="100" />
</ElTable>
</BusinessFormSection>
</template>
<template v-else>
<BusinessFormSection title="当期重点工作回顾">
<ElTable border :data="getPersonalDetail()?.reviewItems || []">
<ElTableColumn prop="itemTitle" label="事项" min-width="160" />
<ElTableColumn prop="workHours" label="工时" width="100" />
<ElTableColumn prop="contentText" label="工作内容" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="reflectionText" label="复盘反思" min-width="220" show-overflow-tooltip />
</ElTable>
</BusinessFormSection>
<BusinessFormSection title="下周期重点工作计划">
<ElTable border :data="getPersonalDetail()?.planItems || []">
<ElTableColumn prop="itemTitle" label="事项" min-width="160" />
<ElTableColumn prop="targetText" label="目标" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="supportNeed" label="支持需求" min-width="220" show-overflow-tooltip />
</ElTable>
</BusinessFormSection>
<BusinessFormSection v-if="reportType === 'weekly'" title="出差信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="是否出差">
{{ weeklyDetail?.isBusinessTrip ? '是' : '否' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="出差天数">
{{ formatEmptyText(weeklyDetail?.totalTravelDays) }}
</ElDescriptionsItem>
</ElDescriptions>
<ElTable class="mt-12px" border :data="weeklyDetail?.travelSegments || []">
<ElTableColumn prop="startDate" label="开始日期" width="120" />
<ElTableColumn prop="endDate" label="结束日期" width="120" />
<ElTableColumn prop="travelDays" label="天数" width="100" />
<ElTableColumn prop="location" label="地点" min-width="160" />
</ElTable>
</BusinessFormSection>
</template>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-detail {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,640 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateMonthlyReport,
fetchCreateProjectReport,
fetchCreateWeeklyReport,
fetchGetMonthlyReportDetail,
fetchGetProjectReportDetail,
fetchGetWeeklyReportDetail,
fetchInitMonthlyReport,
fetchInitProjectReport,
fetchInitWeeklyReport,
fetchPreviewMonthlyReportDefaultDraft,
fetchPreviewProjectReportDefaultDraft,
fetchPreviewWeeklyReportDefaultDraft,
fetchRefreshMonthlyReportDraft,
fetchRefreshProjectReportDraft,
fetchRefreshWeeklyReportDraft,
fetchUpdateMonthlyReport,
fetchUpdateProjectReport,
fetchUpdateWeeklyReport
} from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
createBlankPlanItem,
createBlankProjectItem,
createBlankReviewItem,
createMonthlySaveParams,
createProjectSaveParams,
createWeeklySaveParams,
normalizePlanItems,
normalizeProjectItems,
normalizeReviewItems
} from '../types';
defineOptions({ name: 'WorkReportOperateDialog' });
interface PeriodPayload {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}
interface Props {
operateType: 'add' | 'edit';
reportType: WorkReportType;
rowData?: WorkReportRow | null;
initialPeriod?: PeriodPayload | null;
initialProjectId?: string;
initialFlag?: number;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
initialPeriod: null,
initialProjectId: '',
initialFlag: 1
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(e: 'submitted'): void;
}>();
const loading = ref(false);
const submitting = ref(false);
const baseInfo = ref<WorkReportRow | null>(null);
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
const projectModel = reactive<Api.WorkReport.Project.ProjectReportSaveParams>(createProjectSaveParams());
const title = computed(
() => `${props.operateType === 'add' ? '新增' : '编辑'}${WORK_REPORT_TYPE_LABEL[props.reportType]}`
);
const dialogPreset = computed(() => (props.reportType === 'weekly' ? 'md' : 'lg'));
const activeModel = computed(() => {
if (props.reportType === 'monthly') return monthlyModel;
if (props.reportType === 'project') return projectModel;
return weeklyModel;
});
const baseReporterName = computed(() => {
if (!baseInfo.value) return '--';
if ('projectOwnerName' in baseInfo.value) return baseInfo.value.projectOwnerName || '--';
return baseInfo.value.reporterName || '--';
});
const baseDeptName = computed(() => {
if (!baseInfo.value || 'projectOwnerName' in baseInfo.value) return '--';
return baseInfo.value.reporterDeptName || '--';
});
const basePostName = computed(() => {
if (!baseInfo.value || 'projectOwnerName' in baseInfo.value) return '--';
return baseInfo.value.reporterPostName || '--';
});
function patchPeriod(target: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}) {
if (!props.initialPeriod) return;
Object.assign(target, props.initialPeriod);
}
function patchWeekly(report?: Partial<Api.WorkReport.Weekly.WeeklyReport>) {
Object.assign(
weeklyModel,
createWeeklySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems,
travelSegments: report?.travelSegments
})
);
patchPeriod(weeklyModel);
}
function patchMonthly(report?: Partial<Api.WorkReport.Monthly.MonthlyReport>) {
Object.assign(
monthlyModel,
createMonthlySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems
})
);
patchPeriod(monthlyModel);
}
function patchProject(report?: Partial<Api.WorkReport.Project.ProjectReport>) {
Object.assign(
projectModel,
createProjectSaveParams({
...report,
projectId: report?.projectId || props.initialProjectId,
flag: report?.flag ?? props.initialFlag,
currentItems: report?.currentItems,
nextItems: report?.nextItems
})
);
patchPeriod(projectModel);
}
function applyWeeklyEditableFields(draft: Api.WorkReport.Weekly.WeeklyReport) {
weeklyModel.reviewItems = normalizeReviewItems(draft.reviewItems);
weeklyModel.planItems = normalizePlanItems(draft.planItems);
weeklyModel.travelSegments = draft.travelSegments || [];
}
function applyMonthlyEditableFields(draft: Api.WorkReport.Monthly.MonthlyReport) {
monthlyModel.reviewItems = normalizeReviewItems(draft.reviewItems);
monthlyModel.planItems = normalizePlanItems(draft.planItems);
}
function applyProjectEditableFields(draft: Api.WorkReport.Project.ProjectReport) {
projectModel.projectStatusDesc = draft.projectStatusDesc || '';
projectModel.projectProgressPlan = draft.projectProgressPlan || '';
projectModel.projectKeyPoints = draft.projectKeyPoints || '';
projectModel.projectProblems = draft.projectProblems || '';
projectModel.currentItems = normalizeProjectItems(draft.currentItems);
projectModel.nextItems = normalizeProjectItems(draft.nextItems);
}
function applyEditableFieldsByReportType(
draft:
| Api.WorkReport.Weekly.WeeklyReport
| Api.WorkReport.Monthly.MonthlyReport
| Api.WorkReport.Project.ProjectReport
) {
if (props.reportType === 'weekly') {
applyWeeklyEditableFields(draft as Api.WorkReport.Weekly.WeeklyReport);
return;
}
if (props.reportType === 'monthly') {
applyMonthlyEditableFields(draft as Api.WorkReport.Monthly.MonthlyReport);
return;
}
applyProjectEditableFields(draft as Api.WorkReport.Project.ProjectReport);
}
function createCurrentPeriodPayload(): PeriodPayload {
return {
periodKey: activeModel.value.periodKey,
periodLabel: activeModel.value.periodLabel,
periodStartDate: activeModel.value.periodStartDate,
periodEndDate: activeModel.value.periodEndDate
};
}
async function confirmDraftOverwrite(confirmOverwrite: boolean) {
if (!confirmOverwrite || props.operateType === 'edit') return true;
try {
await ElMessageBox.confirm('重新拉取默认稿会覆盖当前已编辑内容,是否继续?', '覆盖确认', {
type: 'warning',
confirmButtonText: '继续',
cancelButtonText: '取消'
});
return true;
} catch {
return false;
}
}
async function fetchEditDraftRefresh() {
if (props.reportType === 'weekly') {
return fetchRefreshWeeklyReportDraft(weeklyModel);
}
if (props.reportType === 'monthly') {
return fetchRefreshMonthlyReportDraft(monthlyModel);
}
return fetchRefreshProjectReportDraft(projectModel.projectId, {
periodKey: projectModel.periodKey,
periodLabel: projectModel.periodLabel,
periodStartDate: projectModel.periodStartDate,
periodEndDate: projectModel.periodEndDate,
flag: projectModel.flag,
projectStatusDesc: projectModel.projectStatusDesc,
projectProgressPlan: projectModel.projectProgressPlan,
projectKeyPoints: projectModel.projectKeyPoints,
projectProblems: projectModel.projectProblems,
currentItems: projectModel.currentItems,
nextItems: projectModel.nextItems
});
}
async function fetchDefaultDraftPreview() {
const period = createCurrentPeriodPayload();
if (props.reportType === 'weekly') {
return fetchPreviewWeeklyReportDefaultDraft(period);
}
if (props.reportType === 'monthly') {
return fetchPreviewMonthlyReportDefaultDraft(period);
}
return fetchPreviewProjectReportDefaultDraft(projectModel.projectId, {
...period,
flag: projectModel.flag
});
}
async function loadDetail() {
if (!props.rowData?.id) return;
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(props.rowData.id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(props.rowData.id);
} else {
result = await fetchGetProjectReportDetail(props.rowData.id);
}
loading.value = false;
if (result.error || !result.data) return;
baseInfo.value = result.data;
if (props.reportType === 'weekly') patchWeekly(result.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(result.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(result.data as Api.WorkReport.Project.ProjectReport);
}
async function loadInitAndDraft() {
loading.value = true;
let initResult;
if (props.reportType === 'weekly') {
initResult = await fetchInitWeeklyReport();
} else if (props.reportType === 'monthly') {
initResult = await fetchInitMonthlyReport();
} else {
initResult = await fetchInitProjectReport(props.initialProjectId);
}
if (!initResult.error && initResult.data) {
baseInfo.value = initResult.data;
if (props.reportType === 'weekly') patchWeekly(initResult.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(initResult.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(initResult.data as Api.WorkReport.Project.ProjectReport);
}
await pullDefaultDraft(false);
loading.value = false;
}
async function pullDefaultDraft(confirmOverwrite = true) {
const confirmed = await confirmDraftOverwrite(confirmOverwrite);
if (!confirmed) return;
if (props.operateType === 'edit') {
const refreshResult = await fetchEditDraftRefresh();
if (refreshResult.error || !refreshResult.data) return;
applyEditableFieldsByReportType(refreshResult.data);
window.$message?.success('最新数据已刷新');
return;
}
const result = await fetchDefaultDraftPreview();
if (result.error || !result.data) return;
applyEditableFieldsByReportType(result.data);
}
watch(visible, isVisible => {
if (!isVisible) return;
baseInfo.value = null;
if (props.operateType === 'edit') {
loadDetail();
} else {
loadInitAndDraft();
}
});
function addReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
items.push(createBlankReviewItem(items.length));
}
function addPlanItem(items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
items.push(createBlankPlanItem(items.length));
}
function addProjectItem(items: Api.WorkReport.Project.ProjectReportItem[]) {
items.push(createBlankProjectItem());
}
function removeItem<T>(items: T[], index: number) {
if (items.length <= 1) return;
items.splice(index, 1);
}
function validateBase() {
if (!activeModel.value.periodKey || !activeModel.value.periodStartDate || !activeModel.value.periodEndDate) {
window.$message?.warning('请先选择报告周期');
return false;
}
if (props.reportType === 'project' && !projectModel.projectId) {
window.$message?.warning('请选择项目');
return false;
}
return true;
}
async function handleSubmit() {
if (!validateBase()) return;
submitting.value = true;
let result;
if (props.reportType === 'weekly') {
result =
props.operateType === 'add'
? await fetchCreateWeeklyReport(weeklyModel)
: await fetchUpdateWeeklyReport(props.rowData!.id, weeklyModel);
} else if (props.reportType === 'monthly') {
result =
props.operateType === 'add'
? await fetchCreateMonthlyReport(monthlyModel)
: await fetchUpdateMonthlyReport(props.rowData!.id, monthlyModel);
} else {
result =
props.operateType === 'add'
? await fetchCreateProjectReport(projectModel)
: await fetchUpdateProjectReport(props.rowData!.id, projectModel);
}
submitting.value = false;
if (result.error) return;
window.$message?.success(props.operateType === 'add' ? '工作报告已创建' : '工作报告已保存');
visible.value = false;
emit('submitted');
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
:preset="dialogPreset"
:loading="loading"
:confirm-loading="submitting"
max-body-height="76vh"
@confirm="handleSubmit"
>
<div class="work-report-operate">
<BusinessFormSection title="基础信息">
<ElDescriptions :column="3" border size="small">
<ElDescriptionsItem label="填报人">
{{ baseReporterName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="部门">{{ baseDeptName }}</ElDescriptionsItem>
<ElDescriptionsItem label="岗位">{{ basePostName }}</ElDescriptionsItem>
<ElDescriptionsItem label="直属上级">{{ baseInfo?.supervisorName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="周期" :span="2">{{ activeModel.periodLabel || '--' }}</ElDescriptionsItem>
</ElDescriptions>
<div class="mt-12px flex justify-end">
<ElButton plain type="primary" @click="pullDefaultDraft(true)">
<template #icon>
<icon-mdi-refresh class="text-icon" />
</template>
刷新
</ElButton>
</div>
</BusinessFormSection>
<template v-if="reportType === 'project'">
<BusinessFormSection title="项目状况">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目状态">
<ElInput v-model="projectModel.projectStatusDesc" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="整体计划进度">
<ElInput v-model="projectModel.projectProgressPlan" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="要点描述">
<ElInput v-model="projectModel.projectKeyPoints" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目问题">
<ElInput v-model="projectModel.projectProblems" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="本期工作内容">
<div class="work-report-operate__items">
<div v-for="(item, index) in projectModel.currentItems" :key="index" class="work-report-operate__item">
<ElInput v-model="item.itemTitle" placeholder="工作内容" />
<ElInputNumber v-model="item.workHours" :min="0" :precision="1" placeholder="工时" />
<ElInput v-model="item.priorityCode" placeholder="优先级" />
<ElInputNumber v-model="item.progressRate" :min="0" :max="100" :precision="0" placeholder="进度" />
<ElButton link type="danger" @click="removeItem(projectModel.currentItems, index)">删除</ElButton>
</div>
<ElButton plain @click="addProjectItem(projectModel.currentItems)">新增本期工作</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection title="下期计划工作内容">
<div class="work-report-operate__items">
<div v-for="(item, index) in projectModel.nextItems" :key="index" class="work-report-operate__item">
<ElInput v-model="item.itemTitle" placeholder="工作内容" />
<ElInputNumber v-model="item.workHours" :min="0" :precision="1" placeholder="工时" />
<ElInput v-model="item.priorityCode" placeholder="优先级" />
<ElInputNumber v-model="item.progressRate" :min="0" :max="100" :precision="0" placeholder="进度" />
<ElButton link type="danger" @click="removeItem(projectModel.nextItems, index)">删除</ElButton>
</div>
<ElButton plain @click="addProjectItem(projectModel.nextItems)">新增下期工作</ElButton>
</div>
</BusinessFormSection>
</template>
<template v-else>
<BusinessFormSection title="当期重点工作回顾">
<div class="work-report-operate__cards">
<div
v-for="(item, index) in reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems"
:key="index"
class="work-report-operate__card"
>
<ElRow :gutter="16">
<ElCol :span="14">
<ElFormItem label="事项标题">
<ElInput v-model="item.itemTitle" />
</ElFormItem>
</ElCol>
<ElCol :span="6">
<ElFormItem label="工时">
<ElInputNumber v-model="item.workHours" class="w-full" :min="0" :precision="1" />
</ElFormItem>
</ElCol>
<ElCol :span="4" class="flex items-end justify-end pb-16px">
<ElButton
link
type="danger"
@click="
removeItem(reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems, index)
"
>
删除
</ElButton>
</ElCol>
<ElCol :span="12">
<ElFormItem label="工作内容">
<ElInput v-model="item.contentText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="复盘反思">
<ElInput v-model="item.reflectionText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<ElButton
plain
@click="addReviewItem(reportType === 'weekly' ? weeklyModel.reviewItems : monthlyModel.reviewItems)"
>
新增回顾项
</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection title="下周期重点工作计划">
<div class="work-report-operate__cards">
<div
v-for="(item, index) in reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems"
:key="index"
class="work-report-operate__card"
>
<ElRow :gutter="16">
<ElCol :span="20">
<ElFormItem label="计划标题">
<ElInput v-model="item.itemTitle" />
</ElFormItem>
</ElCol>
<ElCol :span="4" class="flex items-end justify-end pb-16px">
<ElButton
link
type="danger"
@click="removeItem(reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems, index)"
>
删除
</ElButton>
</ElCol>
<ElCol :span="12">
<ElFormItem label="目标">
<ElInput v-model="item.targetText" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="支持需求">
<ElInput v-model="item.supportNeed" type="textarea" :rows="3" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<ElButton
plain
@click="addPlanItem(reportType === 'weekly' ? weeklyModel.planItems : monthlyModel.planItems)"
>
新增计划项
</ElButton>
</div>
</BusinessFormSection>
<BusinessFormSection v-if="reportType === 'weekly'" title="出差信息">
<ElFormItem label="是否出差">
<ElSwitch v-model="weeklyModel.isBusinessTrip" />
</ElFormItem>
<div v-if="weeklyModel.isBusinessTrip" class="work-report-operate__items">
<div v-for="(item, index) in weeklyModel.travelSegments" :key="index" class="work-report-operate__item">
<ElDatePicker v-model="item.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<ElDatePicker v-model="item.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<ElInputNumber v-model="item.travelDays" :min="0" :precision="1" placeholder="天数" />
<ElInput v-model="item.location" placeholder="地点" />
<ElButton link type="danger" @click="removeItem(weeklyModel.travelSegments, index)">删除</ElButton>
</div>
<ElButton
plain
@click="
weeklyModel.travelSegments.push({
sort: weeklyModel.travelSegments.length + 1,
travelDays: 0,
location: ''
})
"
>
新增出差分段
</ElButton>
</div>
</BusinessFormSection>
</template>
</div>
</BusinessFormDialog>
</template>
<style scoped>
.work-report-operate {
min-width: 0;
}
.work-report-operate__cards,
.work-report-operate__items {
display: flex;
flex-direction: column;
gap: 12px;
}
.work-report-operate__card,
.work-report-operate__item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background-color: var(--el-fill-color-extra-light);
}
.work-report-operate__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px 120px auto;
gap: 10px;
align-items: center;
}
@media (width <= 900px) {
.work-report-operate__item {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
defineOptions({
name: 'WorkReportPageDialog',
inheritAttrs: false
});
interface Props {
title?: string;
loading?: boolean;
showFooter?: boolean;
approvalMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
loading: false,
showFooter: false,
approvalMode: false
});
const visible = defineModel<boolean>('visible', { default: false });
const route = useRoute();
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
const emit = defineEmits<{
(e: 'close'): void;
}>();
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '75%'));
function handleClose() {
visible.value = false;
}
function syncViewportWidth() {
viewportWidth.value = window.innerWidth;
}
/** 抽屉关闭动画结束后触发 close 事件 */
function onDrawerClosed() {
emit('close');
}
const drawerBodyClass = props.approvalMode
? 'work-report-page-drawer__body work-report-page-drawer__body--approval'
: 'work-report-page-drawer__body';
watch(
() => route.fullPath,
() => {
if (visible.value) {
visible.value = false;
}
}
);
onMounted(() => {
syncViewportWidth();
window.addEventListener('resize', syncViewportWidth);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncViewportWidth);
});
</script>
<template>
<ElDrawer
v-model="visible"
class="work-report-page-drawer"
:class="{ 'work-report-page-drawer--approval': props.approvalMode }"
:body-class="drawerBodyClass"
:title="props.title"
:size="drawerSize"
:close-on-click-modal="false"
append-to-body
@closed="onDrawerClosed"
>
<div v-loading="props.loading" class="work-report-page-drawer__content">
<slot />
</div>
<div v-if="props.showFooter" class="work-report-page-drawer__footer">
<slot name="footer" :close="handleClose" />
</div>
</ElDrawer>
</template>
<style scoped>
:global(.work-report-page-drawer__body) {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 0;
}
:global(.work-report-page-drawer__body--approval) {
padding-bottom: 0;
}
.work-report-page-drawer__content {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.work-report-page-drawer__content :deep(.form-page) {
display: flex;
flex-direction: column;
flex: 1 0 auto;
min-height: 100%;
box-sizing: border-box;
}
.work-report-page-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
</style>

View File

@@ -0,0 +1,857 @@
<script setup lang="ts">
/* eslint-disable complexity, no-nested-ternary, no-void, vue/no-deprecated-filter */
import { computed, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchApproveMonthlyReport,
fetchApproveProjectReport,
fetchApproveWeeklyReport,
fetchCreateMonthlyReport,
fetchCreateProjectReport,
fetchCreateWeeklyReport,
fetchGetMonthlyReportDetail,
fetchGetProjectReportDetail,
fetchGetWeeklyReportDetail,
fetchInitMonthlyReport,
fetchInitProjectReport,
fetchInitWeeklyReport,
fetchPreviewMonthlyReportDefaultDraft,
fetchPreviewProjectReportDefaultDraft,
fetchPreviewWeeklyReportDefaultDraft,
fetchRefreshMonthlyReportDraft,
fetchRefreshProjectReportDraft,
fetchRefreshWeeklyReportDraft,
fetchRejectMonthlyReport,
fetchRejectProjectReport,
fetchRejectWeeklyReport,
fetchSubmitMonthlyReport,
fetchSubmitProjectReport,
fetchSubmitWeeklyReport,
fetchUpdateMonthlyReport,
fetchUpdateProjectReport,
fetchUpdateWeeklyReport
} from '@/service/api';
import type { WorkReportRow, WorkReportType } from '../types';
import {
createMonthlySaveParams,
createProjectSaveParams,
createWeeklySaveParams,
formatPeriodLabel,
getStructuredSections,
normalizePlanItems,
normalizeProjectItems,
normalizeReviewItems
} from '../types';
import WeeklyReportPage from '../../weekly/modules/fill-page.vue';
import MonthlyReportPage from '../../monthly/modules/fill-page.vue';
import ProjectReportPage from '../../project/modules/fill-page.vue';
import MonthlyReportApprovalPage from '../../monthly/modules/approval-page.vue';
import WorkReportActionDialog from './action-dialog.vue';
import WorkReportPageDialog from './page-dialog.vue';
defineOptions({ name: 'WorkReportPrototypePageDialog' });
interface PeriodPayload {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}
interface Props {
reportType: WorkReportType;
mode?: 'add' | 'edit' | 'detail';
scene?: 'fill' | 'detail' | 'approval';
rowData?: WorkReportRow | null;
initialPeriod?: PeriodPayload | null;
initialProjectId?: string;
initialFlag?: number;
}
const props = withDefaults(defineProps<Props>(), {
mode: 'detail',
scene: 'detail',
rowData: null,
initialPeriod: null,
initialProjectId: '',
initialFlag: 1
});
const visible = defineModel<boolean>('visible', { default: false });
const emit = defineEmits<{
(e: 'submitted'): void;
}>();
const REPORT_TYPE_TEXT: Record<WorkReportType, string> = {
weekly: '个人周报',
monthly: '个人月报',
project: '项目半月报'
};
const loading = ref(false);
const actionVisible = ref(false);
const actionSubmitting = ref(false);
const currentActionType = ref<'approve' | 'reject'>('approve');
const currentStage = ref<'form' | 'approval'>('form');
const currentReportId = ref('');
const baseInfo = ref<WorkReportRow | null>(null);
const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApproveParams>({
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
const projectModel = reactive<Api.WorkReport.Project.ProjectReportSaveParams>(createProjectSaveParams());
const currentModel = computed(() => {
if (props.reportType === 'monthly') return monthlyModel;
if (props.reportType === 'project') return projectModel;
return weeklyModel;
});
const currentScene = computed(() => {
if (props.reportType === 'monthly' && currentStage.value === 'approval') {
return 'approval';
}
return props.scene;
});
const dialogTitle = computed(() => {
if (props.reportType === 'monthly' && currentStage.value === 'approval') {
return `${REPORT_TYPE_TEXT.monthly}审批页`;
}
if (currentScene.value === 'approval') {
return `${REPORT_TYPE_TEXT[props.reportType]}审批`;
}
if (props.mode === 'add') return `${REPORT_TYPE_TEXT[props.reportType]}填报页`;
if (props.mode === 'edit') return `${REPORT_TYPE_TEXT[props.reportType]}编辑页`;
return `${REPORT_TYPE_TEXT[props.reportType]}查看页`;
});
const periodText = computed(() => {
const label = currentModel.value.periodLabel || props.rowData?.periodLabel || props.initialPeriod?.periodLabel;
return formatPeriodLabel(label) || '当前周期';
});
function resetModels() {
Object.assign(weeklyModel, createWeeklySaveParams());
Object.assign(monthlyModel, createMonthlySaveParams());
Object.assign(projectModel, createProjectSaveParams());
Object.assign(monthlyApprovalDraft, {
reason: '',
meetingDate: '',
strengthDesc: '',
strengthExample: '',
weaknessDesc: '',
weaknessExample: '',
improvementSuggestion: '',
performanceResult: '',
employeeSignName: '',
employeeSignedDate: '',
supervisorSignName: '',
supervisorSignedDate: ''
});
}
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
const today = dayjs().format('YYYY-MM-DD');
Object.assign(monthlyApprovalDraft, {
employeeSignName: monthlyApprovalDraft.employeeSignName || report?.reporterName || '',
employeeSignedDate: monthlyApprovalDraft.employeeSignedDate || today,
supervisorSignName: monthlyApprovalDraft.supervisorSignName || report?.supervisorName || '',
supervisorSignedDate: monthlyApprovalDraft.supervisorSignedDate || today
});
}
function patchPeriod(target: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
}) {
if (!props.initialPeriod) return;
target.periodKey = props.initialPeriod.periodKey;
target.periodLabel = props.initialPeriod.periodLabel;
target.periodStartDate = props.initialPeriod.periodStartDate;
target.periodEndDate = props.initialPeriod.periodEndDate;
}
function patchWeekly(report?: Partial<Api.WorkReport.Weekly.WeeklyReport>) {
Object.assign(
weeklyModel,
createWeeklySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems,
travelSegments: report?.travelSegments
})
);
if (report && Array.isArray(report.reviewItems) && !report.reviewItems.length) weeklyModel.reviewItems = [];
if (report && Array.isArray(report.planItems) && !report.planItems.length) weeklyModel.planItems = [];
if (report && Array.isArray(report.travelSegments) && !report.travelSegments.length) weeklyModel.travelSegments = [];
if (props.mode === 'add') patchPeriod(weeklyModel);
}
function patchMonthly(report?: Partial<Api.WorkReport.Monthly.MonthlyReport>) {
Object.assign(
monthlyModel,
createMonthlySaveParams({
...report,
reviewItems: report?.reviewItems,
planItems: report?.planItems
})
);
if (report && Array.isArray(report.reviewItems) && !report.reviewItems.length) monthlyModel.reviewItems = [];
if (report && Array.isArray(report.planItems) && !report.planItems.length) monthlyModel.planItems = [];
if (props.mode === 'add') patchPeriod(monthlyModel);
patchMonthlyApprovalDefaults(report);
}
function patchProject(report?: Partial<Api.WorkReport.Project.ProjectReport>) {
Object.assign(
projectModel,
createProjectSaveParams({
...report,
projectId: report?.projectId || props.initialProjectId,
flag: report?.flag ?? props.initialFlag,
currentItems: report?.currentItems,
nextItems: report?.nextItems
})
);
if (report && Array.isArray(report.currentItems) && !report.currentItems.length) projectModel.currentItems = [];
if (report && Array.isArray(report.nextItems) && !report.nextItems.length) projectModel.nextItems = [];
if (props.mode === 'add') patchPeriod(projectModel);
}
function applyWeeklyEditableFields(draft: Api.WorkReport.Weekly.WeeklyReport) {
weeklyModel.isBusinessTrip = draft.isBusinessTrip;
weeklyModel.reviewItems = draft.reviewItems?.length ? normalizeReviewItems(draft.reviewItems) : [];
weeklyModel.planItems = draft.planItems?.length ? normalizePlanItems(draft.planItems) : [];
weeklyModel.travelSegments = draft.travelSegments || [];
}
function applyMonthlyEditableFields(draft: Api.WorkReport.Monthly.MonthlyReport) {
monthlyModel.reviewItems = draft.reviewItems?.length ? normalizeReviewItems(draft.reviewItems) : [];
monthlyModel.planItems = draft.planItems?.length ? normalizePlanItems(draft.planItems) : [];
}
function applyProjectEditableFields(draft: Api.WorkReport.Project.ProjectReport) {
projectModel.projectStatusDesc = draft.projectStatusDesc || '';
projectModel.projectProgressPlan = draft.projectProgressPlan || '';
projectModel.projectKeyPoints = draft.projectKeyPoints || '';
projectModel.projectProblems = draft.projectProblems || '';
projectModel.currentItems = draft.currentItems?.length ? normalizeProjectItems(draft.currentItems) : [];
projectModel.nextItems = draft.nextItems?.length ? normalizeProjectItems(draft.nextItems) : [];
}
function firstMeaningfulValue<T>(...values: Array<T | null | undefined | ''>) {
return values.find(value => value !== null && value !== undefined && value !== '') as T | undefined;
}
function firstPositiveWorkHours(...values: Array<string | number | null | undefined>) {
const matchedValue = values.find(value => {
const numberValue = Number(value ?? 0);
return Number.isFinite(numberValue) && numberValue > 0;
});
return matchedValue ?? firstMeaningfulValue(...values);
}
function mergePersonalDetailBaseInfo<
T extends Api.WorkReport.Weekly.WeeklyReport | Api.WorkReport.Monthly.MonthlyReport
>(detail: T) {
const rowData = props.rowData as Partial<T> | null;
if (!rowData) return detail;
return {
...rowData,
...detail,
reporterDeptName: firstMeaningfulValue(detail.reporterDeptName, rowData.reporterDeptName) ?? null,
reporterPostName: firstMeaningfulValue(detail.reporterPostName, rowData.reporterPostName) ?? null,
submitTime: firstMeaningfulValue(detail.submitTime, rowData.submitTime) ?? null,
totalWorkHours: firstPositiveWorkHours(detail.totalWorkHours, rowData.totalWorkHours)
} as T;
}
function mergeProjectDetailBaseInfo(detail: Api.WorkReport.Project.ProjectReport) {
const rowData = props.rowData as Partial<Api.WorkReport.Project.ProjectReport> | null;
if (!rowData) return detail;
return {
...rowData,
...detail,
submitTime: firstMeaningfulValue(detail.submitTime, rowData.submitTime) ?? null,
totalWorkHours: firstPositiveWorkHours(detail.totalWorkHours, rowData.totalWorkHours)
};
}
async function loadDetail(id: string) {
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchGetWeeklyReportDetail(id);
} else if (props.reportType === 'monthly') {
result = await fetchGetMonthlyReportDetail(id);
} else {
result = await fetchGetProjectReportDetail(id);
}
loading.value = false;
if (result.error || !result.data) return;
const detail =
props.reportType === 'weekly'
? mergePersonalDetailBaseInfo(result.data as Api.WorkReport.Weekly.WeeklyReport)
: props.reportType === 'monthly'
? mergePersonalDetailBaseInfo(result.data as Api.WorkReport.Monthly.MonthlyReport)
: mergeProjectDetailBaseInfo(result.data as Api.WorkReport.Project.ProjectReport);
currentReportId.value = detail.id;
baseInfo.value = detail;
if (props.reportType === 'weekly') patchWeekly(detail as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(detail as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(detail as Api.WorkReport.Project.ProjectReport);
}
async function pullDefaultDraft(confirmOverwrite = false) {
const period = {
periodKey: currentModel.value.periodKey,
periodLabel: currentModel.value.periodLabel,
periodStartDate: currentModel.value.periodStartDate,
periodEndDate: currentModel.value.periodEndDate
};
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchPreviewWeeklyReportDefaultDraft(period);
} else if (props.reportType === 'monthly') {
result = await fetchPreviewMonthlyReportDefaultDraft(period);
} else {
result = await fetchPreviewProjectReportDefaultDraft(projectModel.projectId, {
...period,
flag: projectModel.flag
});
}
loading.value = false;
if (result.error || !result.data) return;
baseInfo.value = {
...(baseInfo.value || {}),
...result.data
} as WorkReportRow;
if (props.reportType === 'weekly') {
const data = result.data as Api.WorkReport.Weekly.WeeklyReport;
applyWeeklyEditableFields(data);
if (confirmOverwrite) {
weeklyModel.travelSegments = data.travelSegments || [];
}
}
if (props.reportType === 'monthly') {
applyMonthlyEditableFields(result.data as Api.WorkReport.Monthly.MonthlyReport);
}
if (props.reportType === 'project') {
applyProjectEditableFields(result.data as Api.WorkReport.Project.ProjectReport);
}
}
async function refreshDraft() {
if (props.mode !== 'edit') {
await pullDefaultDraft(true);
return;
}
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchRefreshWeeklyReportDraft(weeklyModel);
} else if (props.reportType === 'monthly') {
result = await fetchRefreshMonthlyReportDraft(monthlyModel);
} else {
result = await fetchRefreshProjectReportDraft(projectModel.projectId, {
periodKey: projectModel.periodKey,
periodLabel: projectModel.periodLabel,
periodStartDate: projectModel.periodStartDate,
periodEndDate: projectModel.periodEndDate,
flag: projectModel.flag,
projectStatusDesc: projectModel.projectStatusDesc,
projectProgressPlan: projectModel.projectProgressPlan,
projectKeyPoints: projectModel.projectKeyPoints,
projectProblems: projectModel.projectProblems,
currentItems: projectModel.currentItems,
nextItems: projectModel.nextItems
});
}
loading.value = false;
if (result.error || !result.data) return;
if (props.reportType === 'weekly') {
applyWeeklyEditableFields(result.data as Api.WorkReport.Weekly.WeeklyReport);
} else if (props.reportType === 'monthly') {
applyMonthlyEditableFields(result.data as Api.WorkReport.Monthly.MonthlyReport);
} else {
applyProjectEditableFields(result.data as Api.WorkReport.Project.ProjectReport);
}
window.$message?.success('最新数据已刷新');
}
async function loadInitData() {
loading.value = true;
let result;
if (props.reportType === 'weekly') {
result = await fetchInitWeeklyReport();
} else if (props.reportType === 'monthly') {
result = await fetchInitMonthlyReport();
} else {
result = await fetchInitProjectReport(props.initialProjectId);
}
if (!result.error && result.data) {
baseInfo.value = result.data;
if (props.reportType === 'weekly') patchWeekly(result.data as Api.WorkReport.Weekly.WeeklyReport);
if (props.reportType === 'monthly') patchMonthly(result.data as Api.WorkReport.Monthly.MonthlyReport);
if (props.reportType === 'project') patchProject(result.data as Api.WorkReport.Project.ProjectReport);
}
loading.value = false;
await pullDefaultDraft();
}
watch(visible, async isVisible => {
if (!isVisible) return;
currentStage.value = props.reportType === 'monthly' && props.scene === 'approval' ? 'approval' : 'form';
currentReportId.value = props.rowData?.id || '';
baseInfo.value = null;
resetModels();
if (props.mode === 'add') {
if (props.reportType === 'project') {
projectModel.projectId = props.initialProjectId;
projectModel.flag = props.initialFlag;
}
patchPeriod(currentModel.value);
await loadInitData();
return;
}
if (props.rowData?.id) {
await loadDetail(props.rowData.id);
}
});
function hasTextValue(value: unknown) {
const text = String(value ?? '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.trim();
return text.length > 0;
}
function hasMeaningfulStructuredValue(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string') {
const text = value.trim();
if (!text || text === '{}' || text === '[]') return false;
try {
return hasMeaningfulStructuredValue(JSON.parse(text));
} catch {
return hasTextValue(text);
}
}
if (Array.isArray(value)) {
return value.some(item => hasMeaningfulStructuredValue(item));
}
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some(item => hasMeaningfulStructuredValue(item));
}
return false;
}
function hasReviewContent(item: Api.WorkReport.Common.PersonalReportReviewItem) {
return hasTextValue(item.contentText) || hasMeaningfulStructuredValue(item.contentJson);
}
function hasPlanTarget(item: Api.WorkReport.Common.PersonalReportPlanItem) {
return hasTextValue(item.targetText) || hasMeaningfulStructuredValue(item.targetJson);
}
function isCompleteWeeklyTravelSegment(segment: Api.WorkReport.Weekly.WeeklyReportTravelSegment) {
const travelDays = Number(segment.travelDays);
return Boolean(
segment.startDate &&
segment.endDate &&
Number.isFinite(travelDays) &&
travelDays >= 0.5 &&
Number.isInteger(travelDays * 2)
);
}
function hasCompleteWeeklyTravelSegment(items: Api.WorkReport.Weekly.WeeklyReportTravelSegment[]) {
return items.some(isCompleteWeeklyTravelSegment);
}
/**
* 周报"具体工作内容及成果描述"中已记录出差信息("本周差旅"分类、或含"差旅"分类、
* 或结构化任务里带 kind=travel视为已经有完整出差分段不再强制弹出
* "请至少新增一条完整的出差分段"。避免出差点位等历史录入原因导致保存时反复提示。
*/
function hasTravelInfoInWeeklyReview(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => {
if (item.itemTitle?.trim() === '本周差旅') return true;
const sections = getStructuredSections(item.contentJson);
if (sections.some(section => section.category?.trim() === '差旅')) return true;
if (sections.some(section => section.tasks.some(task => task.kind === 'travel'))) return true;
return false;
});
}
function hasCompletePersonalReviewItem(items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasReviewContent(item));
}
function hasCompletePersonalPlanItem(items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
return items.some(item => hasTextValue(item.itemTitle) && hasPlanTarget(item));
}
function getPersonalReviewValidationMessage(label: string, items: Api.WorkReport.Common.PersonalReportReviewItem[]) {
if (!items.length) return `至少要有一项${label}`;
if (!hasCompletePersonalReviewItem(items)) {
return `请完善${label},项目名或我的事项、具体工作内容及成果描述不能为空`;
}
const hasIncompleteItem = items.some(item => !hasTextValue(item.itemTitle) || !hasReviewContent(item));
return hasIncompleteItem ? `请完善${label},项目名或我的事项、具体工作内容及成果描述不能为空` : '';
}
function getPersonalPlanValidationMessage(label: string, items: Api.WorkReport.Common.PersonalReportPlanItem[]) {
if (!items.length) return `至少要有一项${label}`;
if (!hasCompletePersonalPlanItem(items)) {
return `请完善${label},项目名或我的事项、具体目标不能为空`;
}
const hasIncompleteItem = items.some(item => !hasTextValue(item.itemTitle) || !hasPlanTarget(item));
return hasIncompleteItem ? `请完善${label},项目名或我的事项、具体目标不能为空` : '';
}
function hasProjectItem(items: Api.WorkReport.Project.ProjectReportItem[]) {
return items.some(item => hasTextValue(item.itemTitle));
}
function validateRequiredReportItems() {
const messages: string[] = [];
if (props.reportType === 'weekly') {
// 出差信息既可能来自 travelSegments也可能来自具体工作内容及成果描述里的"差旅"段,
// 任何一方已有完整出差信息就不再提示"请至少新增一条完整的出差分段"。
const hasTravelReview =
weeklyModel.isBusinessTrip &&
(hasCompleteWeeklyTravelSegment(weeklyModel.travelSegments) ||
hasTravelInfoInWeeklyReview(weeklyModel.reviewItems));
const reviewMessage = hasTravelReview
? weeklyModel.reviewItems.length
? getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems)
: ''
: getPersonalReviewValidationMessage('当期重点工作回顾', weeklyModel.reviewItems);
const planMessage = getPersonalPlanValidationMessage('下周期重点工作计划', weeklyModel.planItems);
if (weeklyModel.isBusinessTrip && !hasTravelReview) messages.push('请至少新增一条完整的出差分段');
if (reviewMessage) messages.push(reviewMessage);
if (planMessage) messages.push(planMessage);
} else if (props.reportType === 'monthly') {
const reviewMessage = getPersonalReviewValidationMessage('当期重点工作回顾', monthlyModel.reviewItems);
const planMessage = getPersonalPlanValidationMessage('下周期重点工作计划', monthlyModel.planItems);
if (reviewMessage) messages.push(reviewMessage);
if (planMessage) messages.push(planMessage);
} else {
const missingLabels: string[] = [];
if (!hasProjectItem(projectModel.currentItems)) {
missingLabels.push('本期工作内容');
}
if (!hasProjectItem(projectModel.nextItems)) {
missingLabels.push('下期计划工作内容');
}
if (missingLabels.length) messages.push(`至少要有一项${missingLabels.join('、')}`);
}
if (!messages.length) return true;
window.$message?.warning(messages.join(''));
return false;
}
async function persistReport(submitAfterSave: boolean) {
if (!validateRequiredReportItems()) return false;
let result;
if (props.reportType === 'weekly') {
if (currentReportId.value) {
result = await fetchUpdateWeeklyReport(currentReportId.value, weeklyModel);
} else {
result = await fetchCreateWeeklyReport(weeklyModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
} else if (props.reportType === 'monthly') {
if (currentReportId.value) {
result = await fetchUpdateMonthlyReport(currentReportId.value, monthlyModel);
} else {
result = await fetchCreateMonthlyReport(monthlyModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
} else if (currentReportId.value) {
result = await fetchUpdateProjectReport(currentReportId.value, projectModel);
} else {
result = await fetchCreateProjectReport(projectModel);
if (!result.error && result.data) currentReportId.value = result.data;
}
if (result.error) return false;
if (submitAfterSave) {
if (props.reportType === 'weekly') {
const submitResult = await fetchSubmitWeeklyReport(currentReportId.value);
if (submitResult.error) return false;
} else if (props.reportType === 'monthly') {
const submitResult = await fetchSubmitMonthlyReport(currentReportId.value);
if (submitResult.error) return false;
} else {
const submitResult = await fetchSubmitProjectReport(currentReportId.value);
if (submitResult.error) return false;
}
}
return true;
}
async function handleSaveDraft() {
loading.value = true;
const success = await persistReport(false);
loading.value = false;
if (!success) return;
window.$message?.success('工作报告已保存');
visible.value = false;
emit('submitted');
}
async function handleSubmitReport() {
try {
await ElMessageBox.confirm('确认提交当前工作报告吗?', '提交确认', {
type: 'warning',
confirmButtonText: '确认提交',
cancelButtonText: '取消'
});
} catch {
return;
}
loading.value = true;
const success = await persistReport(true);
loading.value = false;
if (!success) return;
window.$message?.success('工作报告已提交');
visible.value = false;
emit('submitted');
}
function handleBack() {
visible.value = false;
}
function handleViewApproval() {
currentStage.value = 'approval';
}
function handleRequestApprove() {
if (props.reportType === 'monthly') {
handleActionSubmit({ ...monthlyApprovalDraft }, 'approve');
return;
}
currentActionType.value = 'approve';
actionVisible.value = true;
}
function handleRequestReject() {
if (props.reportType === 'monthly') {
handleActionSubmit({ reason: monthlyApprovalDraft.reason }, 'reject');
return;
}
currentActionType.value = 'reject';
actionVisible.value = true;
}
function handlePullDefaultDraft() {
refreshDraft();
}
function handleMonthlyApprovalChange(payload: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
Object.assign(monthlyApprovalDraft, payload);
}
async function handleActionSubmit(
payload: Api.WorkReport.Common.StatusActionParams | Api.WorkReport.Monthly.MonthlyReportApproveParams,
actionTypeOverride?: 'approve' | 'reject'
) {
if (!currentReportId.value) return;
const actionType = actionTypeOverride || currentActionType.value;
actionSubmitting.value = true;
let result;
if (props.reportType === 'weekly') {
result =
actionType === 'approve'
? await fetchApproveWeeklyReport(currentReportId.value, payload)
: await fetchRejectWeeklyReport(currentReportId.value, payload);
} else if (props.reportType === 'monthly') {
result =
actionType === 'approve'
? await fetchApproveMonthlyReport(
currentReportId.value,
payload as Api.WorkReport.Monthly.MonthlyReportApproveParams
)
: await fetchRejectMonthlyReport(currentReportId.value, payload);
} else {
result =
actionType === 'approve'
? await fetchApproveProjectReport(currentReportId.value, payload)
: await fetchRejectProjectReport(currentReportId.value, payload);
}
actionSubmitting.value = false;
if (result.error) return;
actionVisible.value = false;
window.$message?.success(actionType === 'approve' ? '审批已通过' : '工作报告已退回');
visible.value = false;
emit('submitted');
}
</script>
<template>
<WorkReportPageDialog
v-model:visible="visible"
:title="dialogTitle"
:loading="loading"
:approval-mode="currentScene === 'approval'"
@close="currentStage = 'form'"
>
<WeeklyReportPage
v-if="reportType === 'weekly'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Weekly.WeeklyReport | null"
:model="weeklyModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
@pull-default-draft="handlePullDefaultDraft"
/>
<MonthlyReportApprovalPage
v-else-if="reportType === 'monthly' && currentStage === 'approval'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
scene="approval"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
:approval-model="monthlyApprovalDraft"
@back="handleBack"
@change-approval="handleMonthlyApprovalChange"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
/>
<MonthlyReportPage
v-else-if="reportType === 'monthly'"
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Monthly.MonthlyReport | null"
:model="monthlyModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@view-approval="handleViewApproval"
@pull-default-draft="handlePullDefaultDraft"
/>
<ProjectReportPage
v-else
:report-type="REPORT_TYPE_TEXT[reportType]"
:period="periodText"
:mode="mode"
:scene="currentScene"
:base-info="baseInfo as Api.WorkReport.Project.ProjectReport | null"
:model="projectModel"
@back="handleBack"
@save="handleSaveDraft"
@submit="handleSubmitReport"
@request-approve="handleRequestApprove"
@request-reject="handleRequestReject"
@pull-default-draft="handlePullDefaultDraft"
/>
</WorkReportPageDialog>
<WorkReportActionDialog
v-model:visible="actionVisible"
:report-type="reportType"
:action-type="currentActionType"
:initial-monthly-approve-data="reportType === 'monthly' ? monthlyApprovalDraft : null"
:loading="actionSubmitting"
append-to-body
@submit="handleActionSubmit"
/>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
/* eslint-disable no-void */
import { computed, onMounted, ref } from 'vue';
import { fetchGetWorkReportStatusDict } from '@/service/api';
import type { SearchField } from '@/components/custom/table-search-fields.vue';
import TableSearchFields from '@/components/custom/table-search-fields.vue';
import { BOOLEAN_TRUE_FALSE_OPTIONS, type WorkReportSearchParams, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportSearch' });
interface Props {
reportType: WorkReportType;
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}
const props = withDefaults(defineProps<Props>(), {
projectOptions: () => []
});
const model = defineModel<WorkReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
const statusDict = ref<Api.WorkReport.Common.WorkReportStatusDict[]>([]);
const statusOptions = computed(() =>
[...statusDict.value]
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
.map(item => ({
label: item.statusName || item.statusCode,
value: item.statusCode
}))
);
const fields = computed<SearchField[]>(() => {
const baseFields: SearchField[] = [
{ key: 'statusCode', label: '状态', type: 'select', options: statusOptions.value, placeholder: '请选择状态' },
{ key: 'periodStartDate', label: '周期', type: 'dateRange', placeholder: '请选择周期' }
];
const monthPeriodField: SearchField = {
key: 'periodStartDate',
label: props.reportType === 'project' ? '月份' : '月份',
type: 'dateRange',
dateRangeType: 'monthrange',
valueFormat: 'YYYY-MM-DD',
placeholder: '请选择月份'
};
if (props.reportType === 'weekly') {
return [
...baseFields,
{
key: 'isBusinessTrip',
label: '是否出差',
type: 'select',
options: BOOLEAN_TRUE_FALSE_OPTIONS,
placeholder: '请选择'
}
];
}
if (props.reportType === 'project') {
return [
baseFields[0],
monthPeriodField,
{
key: 'projectId',
label: '项目',
type: 'select',
options: props.projectOptions.map(item => ({
label: item.projectCode ? `${item.projectName}${item.projectCode}` : item.projectName,
value: item.id
})),
placeholder: '请选择项目'
}
];
}
if (props.reportType === 'monthly') {
return [baseFields[0], monthPeriodField];
}
return baseFields;
});
async function loadStatusDict() {
const { error, data } = await fetchGetWorkReportStatusDict();
statusDict.value = error || !data ? [] : data;
}
onMounted(() => {
loadStatusDict();
});
</script>
<template>
<TableSearchFields v-model="model" :columns="4" :fields="fields" @reset="emit('reset')" @search="emit('search')" />
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
defineOptions({ name: 'WorkReportTabs' });
interface TabOption {
label: string;
name: WorkReportType;
}
const props = defineProps<{
tabs: TabOption[];
}>();
const activeTab = defineModel<WorkReportType>('activeTab', { required: true });
</script>
<template>
<ElCard class="work-report-sidebar" body-class="work-report-sidebar__body">
<div class="work-report-sidebar__header">报告类型</div>
<div class="work-report-sidebar__list">
<div
v-for="tab in tabs"
:key="tab.name"
class="work-report-sidebar__item"
:class="{ 'work-report-sidebar__item--active': activeTab === tab.name }"
@click="activeTab = tab.name"
>
<span class="work-report-sidebar__label">{{ tab.label || WORK_REPORT_TYPE_LABEL[tab.name] }}</span>
</div>
</div>
</ElCard>
</template>
<style scoped>
.work-report-sidebar {
height: 100%;
border: 1px solid var(--el-border-color-light);
box-shadow: none;
}
.work-report-sidebar :deep(.work-report-sidebar__body) {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.work-report-sidebar__header {
padding: 20px 20px 12px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.5px;
}
.work-report-sidebar__list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.work-report-sidebar__item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.work-report-sidebar__item:hover {
background: var(--el-fill-color-light);
}
.work-report-sidebar__item--active {
background: var(--el-color-primary-light-9);
}
.work-report-sidebar__item--active .work-report-sidebar__label {
color: var(--el-color-primary);
font-weight: 600;
}
.work-report-sidebar__label {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1;
}
</style>

View File

@@ -0,0 +1,558 @@
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import type { PaginationData } from '@sa/hooks';
import { getStatusTagType } from '@/constants/status-tag';
dayjs.extend(isoWeek);
export type WorkReportType = Api.WorkReport.Common.ReportType;
export type WorkReportRow =
| Api.WorkReport.Weekly.WeeklyReport
| Api.WorkReport.Monthly.MonthlyReport
| Api.WorkReport.Project.ProjectReport;
export type WorkReportSearchParams =
| Api.WorkReport.Weekly.WeeklyReportSearchParams
| Api.WorkReport.Monthly.MonthlyReportSearchParams
| Api.WorkReport.Project.ProjectReportSearchParams;
export type WorkReportSaveParams =
| Api.WorkReport.Weekly.WeeklyReportSaveParams
| Api.WorkReport.Monthly.MonthlyReportSaveParams
| Api.WorkReport.Project.ProjectReportSaveParams;
export interface WorkReportStructuredTask {
title: string;
detail?: string;
priority?: string | null;
progress?: number | null;
hours?: number | null;
kind?: string | null;
}
export interface WorkReportStructuredSection {
category: string;
tasks: WorkReportStructuredTask[];
}
export const WORK_REPORT_PROJECT_OWNER_PERMISSION = 'project:work-report:project-owner';
export const WORK_REPORT_TYPE_LABEL: Record<WorkReportType, string> = {
weekly: '个人周报',
monthly: '个人月报',
project: '项目半月报'
};
export const WORK_REPORT_STATUS_LABEL: Record<string, string> = {
draft: '待提交',
pending_approval: '待审批',
approved: '已通过',
rejected: '已退回'
};
export const PROJECT_REPORT_FLAG_OPTIONS = [
{ label: '上半月', value: 1 },
{ label: '下半月', value: 2 }
];
export const BOOLEAN_TRUE_FALSE_OPTIONS = [
{ label: '是', value: 'true' },
{ label: '否', value: 'false' }
];
export function getProjectReportFlagLabel(flag?: number | null) {
return PROJECT_REPORT_FLAG_OPTIONS.find(item => item.value === flag)?.label || '--';
}
export function getWorkReportStatusLabel(statusCode?: string | null, statusName?: string | null) {
return statusName || WORK_REPORT_STATUS_LABEL[statusCode || ''] || statusCode || '--';
}
export function resolveWorkReportStatusTagType(statusCode?: string | null) {
return getStatusTagType('workReport', statusCode);
}
export function formatEmptyText(value?: string | number | null) {
if (value === null || value === undefined || value === '') {
return '0';
}
return String(value);
}
export function formatDate(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD') : '--';
}
export function formatDateTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '--';
}
export function formatPeriodDateRange(
rowOrStart: Pick<WorkReportRow, 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
endDate?: string | null
) {
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
const startText = formatDate(startDate);
const endText = formatDate(rangeEndDate);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText}${endText}`;
}
export function formatWeeklyPeriodLabel(
rowOrStart: Pick<WorkReportRow, 'periodLabel' | 'periodStartDate' | 'periodEndDate'> | string | null | undefined,
endDate?: string | null,
periodLabel?: string | null
) {
const startDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodStartDate : rowOrStart;
const rangeEndDate = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodEndDate : endDate;
const fallbackLabel = typeof rowOrStart === 'object' && rowOrStart ? rowOrStart.periodLabel : periodLabel;
const referenceDate = dayjs(startDate || rangeEndDate);
if (referenceDate.isValid()) {
const weekYear = referenceDate.startOf('isoWeek').add(3, 'day').format('YYYY');
return `${weekYear}年第${String(referenceDate.isoWeek()).padStart(2, '0')}`;
}
return formatPeriodLabel(fallbackLabel) || formatPeriodDateRange(startDate, rangeEndDate);
}
export function formatPeriodLabel(value?: string | null) {
return String(value || '')
.trim()
.replace(/\s*(|||)\s*$/u, '');
}
export function formatPeriod(row: Pick<WorkReportRow, 'periodLabel' | 'periodStartDate' | 'periodEndDate'>) {
return formatPeriodLabel(row.periodLabel) || `${formatDate(row.periodStartDate)}${formatDate(row.periodEndDate)}`;
}
export function createInitBaseSearchParams() {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
statusCode: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined
};
}
export function createWeeklySearchParams(): Api.WorkReport.Weekly.WeeklyReportSearchParams {
return {
...createInitBaseSearchParams(),
isBusinessTrip: undefined
};
}
export function createMonthlySearchParams(): Api.WorkReport.Monthly.MonthlyReportSearchParams {
return createInitBaseSearchParams();
}
export function createProjectSearchParams(): Api.WorkReport.Project.ProjectReportSearchParams {
return {
...createInitBaseSearchParams(),
projectId: undefined,
flag: undefined
};
}
export function transformWorkReportPage<T>(
response: { data: Api.WorkReport.Common.PageResult<T> | null; error: unknown },
pageNo: number,
pageSize: number
): PaginationData<T> {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
export function createBlankReviewItem(index = 0): Api.WorkReport.Common.PersonalReportReviewItem {
return {
itemNumber: index + 1,
itemTitle: '',
workHours: 0,
contentText: '',
contentJson: null,
reflectionText: ''
};
}
export function createBlankPlanItem(index = 0): Api.WorkReport.Common.PersonalReportPlanItem {
return {
itemNumber: index + 1,
itemTitle: '',
targetText: '',
targetJson: null,
supportNeed: ''
};
}
export function createBlankProjectItem(): Api.WorkReport.Project.ProjectReportItem {
return {
itemTitle: '',
workHours: 0,
priorityCode: undefined,
progressRate: 0
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
return null;
}
function normalizeStructuredTask(value: unknown): WorkReportStructuredTask | null {
if (!isRecord(value)) return null;
const title = String(value.title ?? value.name ?? '').trim();
if (!title) return null;
return {
title,
detail: String(value.detail ?? value.content ?? '').trim(),
priority: value.priority === null || value.priority === undefined ? null : String(value.priority),
progress: normalizeNumber(value.progress),
hours: normalizeNumber(value.hours),
kind: value.kind === null || value.kind === undefined ? null : String(value.kind)
};
}
function normalizeStructuredSection(value: unknown): WorkReportStructuredSection | null {
if (!isRecord(value)) return null;
const category = String(value.category ?? value.title ?? value.name ?? '').trim();
const rawTasks = Array.isArray(value.tasks) ? value.tasks : [];
const tasks = rawTasks.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
if (!category && !tasks.length) return null;
return {
category: category || '工作内容',
tasks
};
}
function parseJsonLike(value: unknown): unknown {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return null;
}
}
export function getStructuredTasks(value: unknown): WorkReportStructuredTask[] {
const parsed = parseJsonLike(value);
if (Array.isArray(parsed)) {
return parsed.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
}
if (isRecord(parsed) && Array.isArray(parsed.tasks)) {
return parsed.tasks.map(normalizeStructuredTask).filter(Boolean) as WorkReportStructuredTask[];
}
return [];
}
export function getStructuredSections(value: unknown): WorkReportStructuredSection[] {
const parsed = parseJsonLike(value);
if (Array.isArray(parsed)) {
return parsed.map(normalizeStructuredSection).filter(Boolean) as WorkReportStructuredSection[];
}
if (isRecord(parsed) && Array.isArray(parsed.sections)) {
return parsed.sections.map(normalizeStructuredSection).filter(Boolean) as WorkReportStructuredSection[];
}
return [];
}
function joinTextValues(...values: Array<string | null | undefined>) {
return values
.map(value => value?.trim())
.filter(Boolean)
.join('\n');
}
function mergeStructuredSections(sections: WorkReportStructuredSection[]) {
const sectionMap = new Map<string, WorkReportStructuredSection>();
sections.forEach(section => {
const category = section.category || '工作内容';
const existing = sectionMap.get(category);
if (existing) {
existing.tasks.push(...section.tasks);
} else {
sectionMap.set(category, { category, tasks: [...section.tasks] });
}
});
return Array.from(sectionMap.values());
}
function mergeStructuredJson(current: unknown, next: unknown) {
const currentSections = getStructuredSections(current);
const nextSections = getStructuredSections(next);
const sections = mergeStructuredSections([...currentSections, ...nextSections]);
if (sections.length) return JSON.stringify({ sections });
const tasks = [...getStructuredTasks(current), ...getStructuredTasks(next)];
if (tasks.length) return JSON.stringify({ tasks });
return current ?? next ?? null;
}
function groupPersonalReportItemsByTitle<T extends { itemTitle: string }>(
items: T[],
merge: (target: T, source: T) => void
) {
const groupedItems: T[] = [];
const itemMap = new Map<string, T>();
items.forEach((item, index) => {
const title = item.itemTitle.trim();
const key = title || `__blank_${index}`;
const existing = itemMap.get(key);
if (existing) {
merge(existing, item);
return;
}
itemMap.set(key, item);
groupedItems.push(item);
});
return groupedItems.map((item, index) => ({
...item,
itemNumber: index + 1
}));
}
export function normalizeReviewItems(items?: Api.WorkReport.Common.PersonalReportReviewItem[] | null) {
const list = items?.length ? items : [createBlankReviewItem()];
const normalizedItems = list.map((item, index) => ({
...item,
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle || '',
workHours: item.workHours ?? 0,
contentText: item.contentText || '',
contentJson: item.contentJson ?? null,
reflectionText: item.reflectionText || ''
}));
return groupPersonalReportItemsByTitle(normalizedItems, (target, source) => {
target.workHours = Number(target.workHours || 0) + Number(source.workHours || 0);
target.contentText = joinTextValues(target.contentText, source.contentText);
target.contentJson = mergeStructuredJson(target.contentJson, source.contentJson);
target.reflectionText = joinTextValues(target.reflectionText, source.reflectionText);
});
}
export function normalizePlanItems(items?: Api.WorkReport.Common.PersonalReportPlanItem[] | null) {
const list = items?.length ? items : [createBlankPlanItem()];
const normalizedItems = list.map((item, index) => ({
...item,
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle || '',
targetText: item.targetText || '',
targetJson: item.targetJson ?? null,
supportNeed: item.supportNeed || ''
}));
return groupPersonalReportItemsByTitle(normalizedItems, (target, source) => {
target.targetText = joinTextValues(target.targetText, source.targetText);
target.targetJson = mergeStructuredJson(target.targetJson, source.targetJson);
target.supportNeed = joinTextValues(target.supportNeed, source.supportNeed);
});
}
export function normalizeProjectItems(items?: Api.WorkReport.Project.ProjectReportItem[] | null) {
const list = items?.length ? items : [createBlankProjectItem()];
return list.map(item => ({
...item,
itemTitle: item.itemTitle || '',
workHours: item.workHours ?? 0,
priorityCode: item.priorityCode || undefined,
progressRate: item.progressRate ?? 0
}));
}
export function createWeeklySaveParams(
base?: Partial<Api.WorkReport.Weekly.WeeklyReportSaveParams>
): Api.WorkReport.Weekly.WeeklyReportSaveParams {
return {
periodKey: base?.periodKey || '',
periodLabel: base?.periodLabel || '',
periodStartDate: base?.periodStartDate || '',
periodEndDate: base?.periodEndDate || '',
isBusinessTrip: base?.isBusinessTrip ?? false,
reviewItems: normalizeReviewItems(base?.reviewItems),
planItems: normalizePlanItems(base?.planItems),
travelSegments: base?.travelSegments?.length ? base.travelSegments : []
};
}
export function createMonthlySaveParams(
base?: Partial<Api.WorkReport.Monthly.MonthlyReportSaveParams>
): Api.WorkReport.Monthly.MonthlyReportSaveParams {
return {
periodKey: base?.periodKey || '',
periodLabel: base?.periodLabel || '',
periodStartDate: base?.periodStartDate || '',
periodEndDate: base?.periodEndDate || '',
reviewItems: normalizeReviewItems(base?.reviewItems),
planItems: normalizePlanItems(base?.planItems)
};
}
export function createProjectSaveParams(
base?: Partial<Api.WorkReport.Project.ProjectReportSaveParams>
): Api.WorkReport.Project.ProjectReportSaveParams {
const defaultParams: Api.WorkReport.Project.ProjectReportSaveParams = {
projectId: '',
periodKey: '',
periodLabel: '',
periodStartDate: '',
periodEndDate: '',
flag: 1,
projectStatusDesc: '',
projectProgressPlan: '',
projectKeyPoints: '',
projectProblems: '',
currentItems: [createBlankProjectItem()],
nextItems: [createBlankProjectItem()]
};
return {
...Object.assign(defaultParams, base),
currentItems: normalizeProjectItems(base?.currentItems),
nextItems: normalizeProjectItems(base?.nextItems)
};
}
type NavigatorWithLegacySave = Navigator & {
msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean;
};
export function downloadBlob(blob: Blob, filename: string) {
if (!(blob instanceof Blob)) {
window.$message?.error(getBlobErrorMessage(blob) || '导出失败:接口未返回文件流');
return;
}
const downloadFile =
blob instanceof File ? blob : new File([blob], filename, { type: blob.type || 'application/octet-stream' });
const legacyNavigator = window.navigator as NavigatorWithLegacySave;
if (typeof legacyNavigator.msSaveOrOpenBlob === 'function') {
legacyNavigator.msSaveOrOpenBlob(downloadFile, filename);
window.$message?.success('导出文件已开始下载');
return;
}
const url = window.URL.createObjectURL(downloadFile);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 1000);
window.$message?.success('导出文件已开始下载');
}
function getBlobErrorMessage(value: unknown) {
if (!isRecord(value)) return '';
const message = value.msg || value.message || value.error;
return typeof message === 'string' && message.trim() ? message.trim() : '';
}
function safeDecodeFilename(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getResponseHeader(headers: unknown, headerName: string) {
if (!headers) return '';
if (typeof (headers as { get?: unknown }).get === 'function') {
const value = (headers as { get: (name: string) => unknown }).get(headerName);
return value === null || value === undefined ? '' : String(value);
}
if (!isRecord(headers)) return '';
const matchedKey = Object.keys(headers).find(key => key.toLowerCase() === headerName.toLowerCase());
if (!matchedKey) return '';
const value = headers[matchedKey];
return value === null || value === undefined ? '' : String(value);
}
export function getFilenameFromDisposition(disposition?: string | null) {
if (!disposition) return '';
const filenameStarMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (filenameStarMatch?.[1]) {
return safeDecodeFilename(filenameStarMatch[1].replace(/^"|"$/g, ''));
}
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i);
if (filenameMatch?.[1]) {
return safeDecodeFilename(filenameMatch[1]);
}
return '';
}
export function resolveExportFilename(result: { response?: { headers?: unknown } }, fallbackName: string) {
const disposition = getResponseHeader(result.response?.headers, 'content-disposition');
return getFilenameFromDisposition(disposition) || fallbackName;
}
export function createWorkReportContentExportFallbackName(reportType: WorkReportType, reportCount: number) {
const extension = reportCount === 1 ? 'docx' : 'zip';
return `${WORK_REPORT_TYPE_LABEL[reportType]}_${dayjs().format('YYYY-MM-DD')}.${extension}`;
}

View File

@@ -0,0 +1,194 @@
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import weekday from 'dayjs/plugin/weekday';
import type { WorkReportType } from './types';
dayjs.extend(isoWeek);
dayjs.extend(weekday);
export interface WorkReportPeriodOption {
key: string;
label: string;
description: string;
reportType: WorkReportType;
flag?: number;
period: {
periodKey: string;
periodLabel: string;
periodStartDate: string;
periodEndDate: string;
};
}
function formatRangeLabel(start: dayjs.Dayjs, end: dayjs.Dayjs) {
return `${start.format('YYYY-MM-DD')}${end.format('YYYY-MM-DD')}`;
}
export function formatPeriodDisplayLabel(label?: string | null) {
return String(label || '')
.trim()
.replace(/\s*(|||)\s*$/u, '');
}
export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
if (!selectedDate.isValid()) return '';
return `${selectedDate.format('YYYY')}${String(selectedDate.isoWeek()).padStart(2, '0')}`;
}
/* eslint-disable-next-line max-params */
function buildPeriod(reportType: WorkReportType, start: dayjs.Dayjs, end: dayjs.Dayjs, label: string, flag?: number) {
const startText = start.format('YYYY-MM-DD');
const endText = end.format('YYYY-MM-DD');
return {
periodKey: flag ? `${reportType}-${startText}-${endText}-${flag}` : `${reportType}-${startText}-${endText}`,
periodLabel: label,
periodStartDate: startText,
periodEndDate: endText
};
}
export function buildWeeklyPeriodFromDate(date: string | dayjs.Dayjs) {
const selectedDate = dayjs(date);
const start = selectedDate.startOf('isoWeek');
const end = selectedDate.endOf('isoWeek');
return buildPeriod('weekly', start, end, formatRangeLabel(start, end));
}
export function buildMonthlyPeriodFromMonth(month: string | dayjs.Dayjs) {
const selectedMonth = dayjs(month);
const start = selectedMonth.startOf('month');
const end = selectedMonth.endOf('month');
return buildPeriod('monthly', start, end, selectedMonth.format('YYYY-MM'));
}
export function buildProjectPeriodFromMonth(month: string | dayjs.Dayjs, flag: number) {
const selectedMonth = dayjs(month);
const monthStart = selectedMonth.startOf('month');
if (flag === 2) {
const start = monthStart.date(16);
const end = selectedMonth.endOf('month');
return buildPeriod('project', start, end, `${selectedMonth.format('YYYY-MM')} 下半月`, 2);
}
const start = monthStart.startOf('month');
const end = monthStart.date(15);
return buildPeriod('project', start, end, `${selectedMonth.format('YYYY-MM')} 上半月`, 1);
}
export function getWeeklyPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const thisWeekStart = now.startOf('isoWeek');
const thisWeekEnd = now.endOf('isoWeek');
const lastWeekStart = thisWeekStart.subtract(1, 'week');
const lastWeekEnd = thisWeekEnd.subtract(1, 'week');
return [
{
key: 'current-week',
label: '本周',
description: formatRangeLabel(thisWeekStart, thisWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', thisWeekStart, thisWeekEnd, formatRangeLabel(thisWeekStart, thisWeekEnd))
},
{
key: 'last-week',
label: '上周',
description: formatRangeLabel(lastWeekStart, lastWeekEnd),
reportType: 'weekly',
period: buildPeriod('weekly', lastWeekStart, lastWeekEnd, formatRangeLabel(lastWeekStart, lastWeekEnd))
}
];
}
export function getMonthlyPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const thisMonthStart = now.startOf('month');
const thisMonthEnd = now.endOf('month');
const lastMonth = now.subtract(1, 'month');
const lastMonthStart = lastMonth.startOf('month');
const lastMonthEnd = lastMonth.endOf('month');
return [
{
key: 'current-month',
label: '本月',
description: thisMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', thisMonthStart, thisMonthEnd, thisMonthStart.format('YYYY-MM'))
},
{
key: 'last-month',
label: '上月',
description: lastMonthStart.format('YYYY-MM'),
reportType: 'monthly',
period: buildPeriod('monthly', lastMonthStart, lastMonthEnd, lastMonthStart.format('YYYY-MM'))
}
];
}
export function getProjectPeriodOptions(now = dayjs()): WorkReportPeriodOption[] {
const currentMonthStart = now.startOf('month');
const currentMonthEnd = now.endOf('month');
const currentFirstHalfEnd = currentMonthStart.date(15);
const currentSecondHalfStart = currentMonthStart.date(16);
const previousMonth = now.subtract(1, 'month');
const previousMonthStart = previousMonth.startOf('month');
const previousMonthEnd = previousMonth.endOf('month');
const previousSecondHalfStart = previousMonthStart.date(16);
const isCurrentFirstHalf = now.date() <= 15;
const currentOption: WorkReportPeriodOption = isCurrentFirstHalf
? {
key: 'current-first-half',
label: '上半月',
description: `${now.format('YYYY-MM')} 上半月`,
reportType: 'project',
flag: 1,
period: buildPeriod('project', currentMonthStart, currentFirstHalfEnd, `${now.format('YYYY-MM')} 上半月`, 1)
}
: {
key: 'current-second-half',
label: '下半月',
description: `${now.format('YYYY-MM')} 下半月`,
reportType: 'project',
flag: 2,
period: buildPeriod('project', currentSecondHalfStart, currentMonthEnd, `${now.format('YYYY-MM')} 下半月`, 2)
};
const previousOption: WorkReportPeriodOption = isCurrentFirstHalf
? {
key: 'previous-second-half',
label: '下半月',
description: `${previousMonth.format('YYYY-MM')} 下半月`,
reportType: 'project',
flag: 2,
period: buildPeriod(
'project',
previousSecondHalfStart,
previousMonthEnd,
`${previousMonth.format('YYYY-MM')} 下半月`,
2
)
}
: {
key: 'previous-first-half',
label: '上半月',
description: `${now.format('YYYY-MM')} 上半月`,
reportType: 'project',
flag: 1,
period: buildPeriod('project', currentMonthStart, currentFirstHalfEnd, `${now.format('YYYY-MM')} 上半月`, 1)
};
return [currentOption, previousOption];
}
export function getReportTypePeriodOptions(now = dayjs()) {
return {
weekly: getWeeklyPeriodOptions(now),
monthly: getMonthlyPeriodOptions(now),
project: getProjectPeriodOptions(now)
} as const;
}

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