15 Commits

Author SHA1 Message Date
dk
622d8d5a4d feat(产品需求、项目需求): 支持手动录入"来源业务编号",前端的sourceBizId改为sourceBizCode,也支持按"来源业务编号"搜索。 2026-06-15 11:30:07 +08:00
dk
3c1cf6c7fa feat(工作报告、加班申请团队视角): 工作报告、加班申请现在可以查看团队视角了(查看下属)。
fix(工作报告): 修复周报在新增/编辑时,不能展示工作日志。
2026-06-14 23:57:42 +08:00
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
200 changed files with 22466 additions and 15221 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

@@ -0,0 +1,108 @@
<script setup lang="ts">
defineOptions({ name: 'SubordinateSelector' });
interface Props {
loading?: boolean;
data?: Api.SystemManage.MySubordinateTreeNode | null;
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
data: null,
emptyText: '暂无下属数据'
});
const selectedUserId = defineModel<string | null>('selectedUserId', {
default: null
});
function handleNodeClick(node: Api.SystemManage.MySubordinateTreeNode) {
selectedUserId.value = node.userId;
}
function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
const label = node.isRoot ? '全部下属' : node.userNickname;
return `${label}${node.subordinateCount ? `${node.subordinateCount}` : ''}`;
}
</script>
<template>
<ElCard class="subordinate-selector" body-class="subordinate-selector__body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<span class="text-14px font-600">团队成员</span>
<ElTag v-if="props.data" effect="plain">{{ props.data.subordinateCount }}</ElTag>
</div>
</template>
<div v-loading="props.loading" class="subordinate-selector__content">
<ElEmpty v-if="!props.data" :image-size="88" :description="props.emptyText" />
<ElTree
v-else
:data="[props.data]"
node-key="userId"
:current-node-key="selectedUserId || undefined"
:props="{ label: 'userNickname', children: 'children' }"
highlight-current
default-expand-all
expand-on-click-node
class="subordinate-selector__tree"
@node-click="handleNodeClick"
>
<template #default="{ data: node }">
<span class="subordinate-selector__node-label">{{ renderNodeLabel(node) }}</span>
</template>
</ElTree>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.subordinate-selector {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--el-border-color-light);
box-shadow: none;
}
:deep(.subordinate-selector__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 12px;
}
.subordinate-selector__content {
flex: 1;
min-height: 240px;
overflow: auto;
}
.subordinate-selector__tree {
height: 100%;
background: transparent;
}
.subordinate-selector__node-label {
display: inline-flex;
align-items: center;
min-width: 0;
color: var(--el-text-color-regular);
}
:deep(.subordinate-selector__tree .el-tree-node__content) {
height: 36px;
border-radius: 8px;
}
:deep(.subordinate-selector__tree .el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
:deep(.subordinate-selector__tree .el-tree-node.is-current > .el-tree-node__content) {
background: var(--el-color-primary-light-9);
}
</style>

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

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TeamViewMode } from '@/views/personal-center/shared/team-dashboard';
defineOptions({ name: 'TeamContextPanel' });
interface Props {
loading?: boolean;
selectedLabel?: string;
subordinateCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
selectedLabel: '',
subordinateCount: 0
});
const mode = defineModel<TeamViewMode>('mode', {
required: true
});
const scopeOptions = computed(() => [
{ label: '个人视角', value: 'self' satisfies TeamViewMode },
{ label: '团队视角', value: 'team' satisfies TeamViewMode }
]);
const contextText = computed(() => {
if (mode.value === 'self') {
return '当前查看我自己的数据。';
}
if (props.selectedLabel) {
return `当前范围:${props.selectedLabel}`;
}
return '当前查看团队数据。';
});
</script>
<template>
<ElCard class="team-context-panel" body-class="team-context-panel__body">
<div v-loading="props.loading" class="team-context-panel__layout">
<div class="team-context-panel__controls">
<ElSegmented v-model="mode" :options="scopeOptions" class="team-context-panel__segmented" />
</div>
<div class="team-context-panel__info">
<div class="team-context-panel__info-main">
<div class="team-context-panel__info-item">
<span class="team-context-panel__info-label">当前范围</span>
<strong class="team-context-panel__info-value">
{{ props.selectedLabel || (mode === 'self' ? '我自己' : '--') }}
</strong>
</div>
<div v-if="mode === 'team'" class="team-context-panel__info-item">
<span class="team-context-panel__info-label">下属人数</span>
<strong class="team-context-panel__info-value">{{ props.subordinateCount }}</strong>
</div>
</div>
<p class="team-context-panel__info-desc">{{ contextText }}</p>
<div v-if="$slots.default" class="team-context-panel__summary">
<slot />
</div>
</div>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.team-context-panel {
border: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-blank);
box-shadow: none;
}
:deep(.team-context-panel__body) {
padding: 16px 18px;
}
.team-context-panel__layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.team-context-panel__controls {
flex-shrink: 0;
}
:deep(.team-context-panel__segmented) {
padding: 6px;
background: var(--el-fill-color-light);
border-radius: 12px;
}
:deep(.team-context-panel__segmented .el-segmented__item) {
min-width: 96px;
min-height: 40px;
padding: 0 22px;
font-size: 14px;
}
.team-context-panel__info {
flex: 1;
min-width: 0;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
}
.team-context-panel__info-main {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.team-context-panel__info-item {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 180px;
}
.team-context-panel__info-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.team-context-panel__info-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.5;
}
.team-context-panel__info-desc {
margin: 10px 0 0;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.team-context-panel__summary {
margin-top: 14px;
}
@media (width <= 1200px) {
.team-context-panel__layout {
flex-direction: column;
align-items: stretch;
}
.team-context-panel__info {
padding-left: 0;
padding-top: 14px;
border-left: none;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

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;
watch(searchKeyword, () => { const token = state.token;
unreadPageSize.value = PAGE_SIZE; state.loading = true;
readPageSize.value = PAGE_SIZE;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
}); });
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然 if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => {
applyKeywordSearch();
});
watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null; 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>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer"> </div>
<SvgIcon icon="mdi:close" /> </template>
</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', name: 'personal-center_work-report',
path: '/plugin', path: '/personal-center/work-report',
component: 'layout.base', component: 'view.personal-center_work-report',
meta: { meta: {
title: '插件示例', title: 'personal-center_work-report',
i18nKey: 'route.plugin', i18nKey: 'route.personal-center_work-report',
order: 7, icon: 'mdi:file-chart-outline',
icon: 'clarity:plugin-line' order: 3,
},
children: [
{
name: 'plugin_barcode',
path: '/plugin/barcode',
component: 'view.plugin_barcode',
meta: {
title: 'plugin_barcode',
i18nKey: 'route.plugin_barcode',
icon: 'ic:round-barcode'
}
},
{
name: 'plugin_charts',
path: '/plugin/charts',
meta: {
title: 'plugin_charts',
i18nKey: 'route.plugin_charts',
icon: 'mdi:chart-areaspline'
},
children: [
{
name: 'plugin_charts_antv',
path: '/plugin/charts/antv',
component: 'view.plugin_charts_antv',
meta: {
title: 'plugin_charts_antv',
i18nKey: 'route.plugin_charts_antv',
icon: 'hugeicons:flow-square'
}
},
{
name: 'plugin_charts_echarts',
path: '/plugin/charts/echarts',
component: 'view.plugin_charts_echarts',
meta: {
title: 'plugin_charts_echarts',
i18nKey: 'route.plugin_charts_echarts',
icon: 'simple-icons:apacheecharts'
}
},
{
name: 'plugin_charts_vchart',
path: '/plugin/charts/vchart',
component: 'view.plugin_charts_vchart',
meta: {
title: 'plugin_charts_vchart',
i18nKey: 'route.plugin_charts_vchart',
localIcon: 'visactor'
}
}
]
},
{
name: 'plugin_copy',
path: '/plugin/copy',
component: 'view.plugin_copy',
meta: {
title: 'plugin_copy',
i18nKey: 'route.plugin_copy',
icon: 'mdi:clipboard-outline'
}
},
{
name: 'plugin_excel',
path: '/plugin/excel',
component: 'view.plugin_excel',
meta: {
title: 'plugin_excel',
i18nKey: 'route.plugin_excel',
icon: 'ri:file-excel-2-line',
keepAlive: true keepAlive: true
}
},
{
name: 'plugin_gantt',
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: 'personal-center_work-report_weekly',
path: '/personal-center/work-report/weekly',
component: 'view.personal-center_work-report_weekly',
meta: {
title: 'personal-center_work-report_weekly',
i18nKey: 'route.personal-center_work-report_weekly',
hideInMenu: true,
activeMenu: 'personal-center_work-report'
} }
} }
] ]
},
{
name: 'plugin_icon',
path: '/plugin/icon',
component: 'view.plugin_icon',
meta: {
title: 'plugin_icon',
i18nKey: 'route.plugin_icon',
localIcon: 'custom-icon'
}
},
{
name: 'plugin_map',
path: '/plugin/map',
component: 'view.plugin_map',
meta: {
title: 'plugin_map',
i18nKey: 'route.plugin_map',
icon: 'mdi:map'
}
},
{
name: 'plugin_pdf',
path: '/plugin/pdf',
component: 'view.plugin_pdf',
meta: {
title: 'plugin_pdf',
i18nKey: 'route.plugin_pdf',
icon: 'uiw:file-pdf'
}
},
{
name: 'plugin_pinyin',
path: '/plugin/pinyin',
component: 'view.plugin_pinyin',
meta: {
title: 'plugin_pinyin',
i18nKey: 'route.plugin_pinyin',
icon: 'entypo-social:google-hangouts'
}
},
{
name: 'plugin_print',
path: '/plugin/print',
component: 'view.plugin_print',
meta: {
title: 'plugin_print',
i18nKey: 'route.plugin_print',
icon: 'mdi:printer'
}
},
{
name: 'plugin_swiper',
path: '/plugin/swiper',
component: 'view.plugin_swiper',
meta: {
title: 'plugin_swiper',
i18nKey: 'route.plugin_swiper',
icon: 'simple-icons:swiper'
}
},
{
name: 'plugin_tables',
path: '/plugin/tables',
meta: {
title: 'plugin_tables',
i18nKey: 'route.plugin_tables',
icon: 'icon-park-outline:table'
},
children: [
{
name: 'plugin_tables_vtable',
path: '/plugin/tables/vtable',
component: 'view.plugin_tables_vtable',
meta: {
title: 'plugin_tables_vtable',
i18nKey: 'route.plugin_tables_vtable',
localIcon: 'visactor'
}
}
]
},
{
name: 'plugin_typeit',
path: '/plugin/typeit',
component: 'view.plugin_typeit',
meta: {
title: 'plugin_typeit',
i18nKey: 'route.plugin_typeit',
icon: 'mdi:typewriter'
}
},
{
name: 'plugin_video',
path: '/plugin/video',
component: 'view.plugin_video',
meta: {
title: 'plugin_video',
i18nKey: 'route.plugin_video',
icon: 'mdi:video'
}
} }
] ]
}, },

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,16 +24,18 @@ 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;
}; };
type TeamOvertimeSummaryResponse = Api.OvertimeApplication.TeamOvertimeSummary;
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) { function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value; return value;
@@ -81,18 +77,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
}; };
} }
@@ -102,6 +96,18 @@ function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSear
query.append('pageNo', String(params.pageNo ?? 1)); query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10)); query.append('pageSize', String(params.pageSize ?? 10));
if (params.applicantIds !== null && params.applicantIds !== undefined) {
if (params.applicantIds.length) {
params.applicantIds.forEach(item => {
if (item) {
query.append('applicantIds', item);
}
});
} else {
query.append('applicantIds', '');
}
}
if (params.keyword) { if (params.keyword) {
query.append('keyword', params.keyword); query.append('keyword', params.keyword);
} }
@@ -240,12 +246,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,18 +276,42 @@ 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
);
}
export async function fetchGetTeamOvertimeSummary(params: Api.OvertimeApplication.TeamOvertimeSummaryParams = {}) {
const result = await request<TeamOvertimeSummaryResponse>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamOvertimeSummaryResponse>, data => data);
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) { export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params); const query = createPageQuery(params);

View File

@@ -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
);
} }
/** 获取产品详情 */ /** 获取产品详情 */
@@ -189,7 +210,7 @@ type RequirementResponse = Omit<
| 'proposerId' | 'proposerId'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'implementProjectId' | 'implementProjectId'
| 'sourceBizId' | 'sourceBizCode'
| 'attachments' | 'attachments'
> & { > & {
id: string | number; id: string | number;
@@ -199,7 +220,7 @@ type RequirementResponse = Omit<
currentHandlerUserId?: string | number | null; currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null; implementProjectId?: string | number | null;
implementProjectName?: string | null; implementProjectName?: string | null;
sourceBizId?: string | number | null; sourceBizCode?: string | null;
attachments?: AttachmentItemResponse[] | null; attachments?: AttachmentItemResponse[] | null;
children?: RequirementResponse[]; children?: RequirementResponse[];
}; };
@@ -271,7 +292,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId), implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null, implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizCode: requirement.sourceBizCode ?? null,
attachments: normalizeAttachments(requirement.attachments), attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeRequirement) children: requirement.children?.map(normalizeRequirement)
}; };

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,
@@ -892,7 +1026,7 @@ const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requir
type ProjectRequirementResponse = Omit< type ProjectRequirementResponse = Omit<
Api.Project.ProjectRequirement, Api.Project.ProjectRequirement,
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments' 'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizCode' | 'attachments'
> & { > & {
id: string | number; id: string | number;
projectId: string | number; projectId: string | number;
@@ -900,7 +1034,7 @@ type ProjectRequirementResponse = Omit<
moduleId: string | number; moduleId: string | number;
proposerId: string | number; proposerId: string | number;
currentHandlerUserId?: string | number | null; currentHandlerUserId?: string | number | null;
sourceBizId?: string | number | null; sourceBizCode?: string | null;
attachments?: AttachmentItemResponse[] | null; attachments?: AttachmentItemResponse[] | null;
children?: ProjectRequirementResponse[]; children?: ProjectRequirementResponse[];
}; };
@@ -956,7 +1090,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
moduleId: normalizeStringId(requirement.moduleId), moduleId: normalizeStringId(requirement.moduleId),
proposerId: normalizeStringId(requirement.proposerId), proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizCode: requirement.sourceBizCode ?? null,
attachments: normalizeAttachments(requirement.attachments), attachments: normalizeAttachments(requirement.attachments),
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0, progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
children: requirement.children?.map(normalizeProjectRequirement) children: requirement.children?.map(normalizeProjectRequirement)

View File

@@ -118,6 +118,11 @@ type UserManagementRelationTreeResponse = Omit<
children?: UserManagementRelationTreeResponse[] | null; children?: UserManagementRelationTreeResponse[] | null;
}; };
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
userId: string | number;
children?: MySubordinateTreeNodeResponse[] | null;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple { function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return { return {
...user, ...user,
@@ -181,6 +186,14 @@ function normalizeUserManagementRelationTree(
}; };
} }
function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Api.SystemManage.MySubordinateTreeNode {
return {
...node,
userId: normalizeStringId(node.userId),
children: node.children?.map(normalizeMySubordinateTreeNode) ?? null
};
}
/** 获取角色分页 */ /** 获取角色分页 */
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) { export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params); const query = createRolePageQuery(params);
@@ -712,6 +725,17 @@ export async function fetchGetUserManagementRelationQuery(query: UserManagementR
); );
} }
/** 获取当前登录用户下属树 */
export async function fetchGetMySubordinateTree() {
return request<MySubordinateTreeNodeResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/my-subordinate-tree`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<MySubordinateTreeNodeResponse>, normalizeMySubordinateTreeNode)
);
}
/** /**
* 获取用户管理链路详情 * 获取用户管理链路详情
* *

View File

@@ -0,0 +1,980 @@
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;
};
type TeamReportPendingUserResponse = Omit<Api.WorkReport.Common.TeamReportPendingUser, 'userId'> & {
userId: StringIdResponse;
};
type TeamReportSummaryResponse = Omit<Api.WorkReport.Common.TeamReportSummary, 'unsubmittedUsers'> & {
unsubmittedUsers?: TeamReportPendingUserResponse[] | null;
};
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 appendNullableArrayFlag(
query: URLSearchParams,
key: string,
values?: Array<string | null | undefined> | null
) {
if (values === null || values === undefined) return;
if (!values.length) {
query.append(key, '');
return;
}
appendArray(query, key, values);
}
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);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
return query.toString();
}
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
return query.toString();
}
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendNullableArrayFlag(query, 'projectOwnerIds', params.projectOwnerIds);
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 normalizeTeamReportSummary(response: TeamReportSummaryResponse): Api.WorkReport.Common.TeamReportSummary {
return {
...response,
unsubmittedUsers:
response.unsubmittedUsers?.map(item => ({
...item,
userId: normalizeStringId(item.userId)
})) ?? []
};
}
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 fetchGetTeamReportSummary(params: Api.WorkReport.Common.TeamReportSummaryParams) {
const result = await request<TeamReportSummaryResponse>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/summary`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TeamReportSummaryResponse>, normalizeTeamReportSummary);
}
export async function fetchRemindTeamReport(data: Api.WorkReport.Common.TeamReportRemindParams) {
const result = await request<Api.WorkReport.Common.TeamReportRemindResult>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/team/remind`,
method: 'post',
data: {
...data,
userIds: data.userIds && data.userIds.length ? data.userIds : undefined
}
});
return mapServiceResult(
result as ServiceRequestResult<Api.WorkReport.Common.TeamReportRemindResult>,
payload => payload
);
}
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;
@@ -32,6 +32,7 @@ declare namespace Api {
type OvertimeApplicationSearchParams = CommonType.RecordNullable< type OvertimeApplicationSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & { Pick<PageParams, 'pageNo' | 'pageSize'> & {
applicantIds: string[] | null;
keyword: string; keyword: string;
applicantName: string; applicantName: string;
approverId: string; approverId: string;
@@ -59,20 +60,53 @@ 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;
}
interface TeamOvertimeSummaryParams {
month?: string | null;
}
interface TeamOvertimeSummary {
month: string;
totalApplicationCount: number;
pendingCount: number;
approvedCount: number;
rejectedCount: number;
}
} }
} }

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[];
} }
>; >;
@@ -313,8 +332,8 @@ declare namespace Api {
categoryName?: string | null; categoryName?: string | null;
/** 需求来源类型 */ /** 需求来源类型 */
sourceType: RequirementSourceType; sourceType: RequirementSourceType;
/** 需求来源业务ID */ /** 来源业务编号 */
sourceBizId?: string | null; sourceBizCode?: string | null;
/** 优先级0低 1中 2高 3紧急 */ /** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority; priority: RequirementPriority;
/** 优先级名称 */ /** 优先级名称 */
@@ -489,7 +508,7 @@ declare namespace Api {
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick< Pick<
Requirement, Requirement,
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType' 'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
> & { > & {
productId: string; productId: string;
title?: string; title?: string;
@@ -507,6 +526,7 @@ declare namespace Api {
| 'attachments' | 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'sourceBizCode'
| 'proposerId' | 'proposerId'
| 'proposerNickname' | 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'

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;
@@ -870,8 +1160,8 @@ declare namespace Api {
categoryName?: string | null; categoryName?: string | null;
/** 需求来源类型 */ /** 需求来源类型 */
sourceType: ProjectRequirementSourceType; sourceType: ProjectRequirementSourceType;
/** 来源业务 ID */ /** 来源业务编号 */
sourceBizId?: string | null; sourceBizCode?: string | null;
/** 优先级 */ /** 优先级 */
priority: ProjectRequirementPriority; priority: ProjectRequirementPriority;
/** 优先级名称 */ /** 优先级名称 */
@@ -995,7 +1285,7 @@ declare namespace Api {
Pick<PageParams, 'pageNo' | 'pageSize'> & Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick< Pick<
ProjectRequirement, ProjectRequirement,
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType' 'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
> & { > & {
projectId: string; projectId: string;
title: string; title: string;
@@ -1013,6 +1303,7 @@ declare namespace Api {
| 'attachments' | 'attachments'
| 'category' | 'category'
| 'priority' | 'priority'
| 'sourceBizCode'
| 'proposerId' | 'proposerId'
| 'proposerNickname' | 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'

View File

@@ -386,6 +386,24 @@ declare namespace Api {
children?: UserManagementRelationTreeRespVO[] | null; children?: UserManagementRelationTreeRespVO[] | null;
} }
/**
* 当前登录用户的下属树
*
* 用于团队视角选择器;根节点代表“全部下属范围”
*/
interface MySubordinateTreeNode {
/** 用户 ID */
userId: string;
/** 用户昵称 */
userNickname: string;
/** 是否为当前登录用户根节点 */
isRoot: boolean;
/** 全链路下属人数 */
subordinateCount: number;
/** 下级用户列表 */
children?: MySubordinateTreeNode[] | null;
}
/** /**
* 用户管理链路保存参数 * 用户管理链路保存参数
* *

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

@@ -0,0 +1,328 @@
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[];
}
interface TeamReportPendingUser {
userId: string;
userNickname: string;
}
interface TeamReportSummary {
totalShouldSubmit: number;
submittedCount: number;
unsubmittedCount: number;
pendingApprovalCount: number;
unsubmittedUsers: TeamReportPendingUser[];
}
interface TeamReportSummaryParams {
reportType: ReportType;
periodKey: string;
}
interface TeamReportRemindParams {
reportType: ReportType;
periodKey: string;
userIds?: string[] | null;
}
interface TeamReportRemindResult {
remindedCount: number;
}
}
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 & {
reporterIds?: string[] | null;
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 & {
reporterIds?: string[] | null;
};
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 & {
projectOwnerIds?: string[] | null;
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']
@@ -176,12 +178,14 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default'] SoybeanAvatar: typeof import('./../components/custom/soybean-avatar.vue')['default']
SubordinateSelector: typeof import('./../components/custom/subordinate-selector.vue')['default']
SvgIcon: typeof import('./../components/custom/svg-icon.vue')['default'] SvgIcon: typeof import('./../components/custom/svg-icon.vue')['default']
SystemLogo: typeof import('./../components/common/system-logo.vue')['default'] SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default'] TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default'] TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default'] TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default'] TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
TeamContextPanel: typeof import('./../components/custom/team-context-panel.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default'] ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default'] UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default'] WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']

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"
/>
<path d="M -58 -120 L 58 -120 M -46 -150 L 46 -150" />
<path d="M -58 -120 L -58 -110 M 58 -120 L 58 -110 M -46 -150 L -46 -140 M 46 -150 L 46 -140" />
</g>
<!-- 塔间导线 -->
<path v-for="line in powerLines" :key="line.path" :d="line.path" />
<!-- 风机 -->
<g
v-for="turbine in turbines"
:key="turbine.key"
:transform="`translate(${turbine.x}, 870) scale(${turbine.s})`"
>
<path d="M -3 0 L 0 -120 M 3 0 L 0 -120" />
<g transform="translate(0, -120)">
<g>
<path d="M 0 0 L 0 -52" />
<path d="M 0 0 L 0 -52" transform="rotate(120)" />
<path d="M 0 0 L 0 -52" transform="rotate(240)" />
<animateTransform
attributeName="transform"
type="rotate"
from="0 0 0"
to="360 0 0"
:dur="turbine.dur"
repeatCount="indefinite"
/>
</g>
</g>
<circle cx="0" cy="-120" r="3" class="industry__hub" />
</g>
</g>
<!-- 导线上的电流光点 -->
<circle v-for="spark in lineSparks" :key="spark.key" r="2.5" class="travel-dot industry__spark">
<animateMotion :dur="spark.dur" :begin="spark.begin" repeatCount="indefinite" :path="spark.path" />
</circle>
<!-- 汇入节点脉冲 -->
<g v-for="point in mergePoints" :key="`merge-${point.x}`">
<circle :cx="point.x" cy="470" r="5" :fill="point.color" />
<circle :cx="point.x" cy="470" r="5" :stroke="point.color" class="merge-pulse" />
</g>
</svg>
<!-- 角色徽章 -->
<span v-for="chip in roleChips" :key="chip.label" class="role-chip" :style="chip.style">
<i class="role-chip__dot" :style="{ backgroundColor: chip.color }"></i>
{{ chip.label }}
</span>
</div> </div>
<!-- 顶部品牌 -->
<header class="scene-header reveal" style="--d: 0s">
<SystemLogo class="text-36px" />
<span class="scene-header__name">{{ $t('system.title') }}</span>
</header> </header>
<main class="pt-15px">
<div class="pt-15px"> <!-- 主文案 -->
<div class="scene-hero" :style="layerStyle(10)">
<p class="scene-hero__eyebrow reveal" style="--d: 0.15s">BUILD TOGETHER · GUARD POWER QUALITY</p>
<h1 class="scene-hero__slogan reveal" style="--d: 0.25s">
独行快
<span class="scene-hero__comma"></span>
<br />
众行
<em></em>
</h1>
<p class="scene-hero__sub reveal" style="--d: 0.4s">每一次提交都让电能质量的守护更进一步</p>
</div>
<!-- 登录卡片 -->
<div class="login-card">
<header class="login-card__header reveal" style="--d: 0.3s">
<SystemLogo class="login-card__logo text-52px" />
<h2 class="login-card__title">{{ $t('system.title') }}</h2>
<p class="login-card__subtitle">欢迎回来开始今天的协作</p>
</header>
<main class="reveal" style="--d: 0.45s">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear> <Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" /> <component :is="activeModule.component" />
</Transition> </Transition>
</div>
</main> </main>
</div> </div>
</ElCard>
<footer class="scene-footer reveal" style="--d: 0.6s">
© {{ currentYear }} 南京灿能电力自动化股份有限公司 · {{ $t('system.title') }}
</footer>
</div> </div>
</template> </template>
<style scoped></style> <style scoped lang="scss">
.login-scene {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
height: 100%;
padding-right: 9vw;
overflow: hidden;
background:
radial-gradient(90% 70% at 80% 42%, color-mix(in srgb, var(--brand) 10%, transparent) 0%, transparent 60%),
radial-gradient(80% 60% at 6% 92%, rgb(56 189 248 / 10%) 0%, transparent 60%),
radial-gradient(70% 50% at 18% 8%, rgb(14 165 233 / 6%) 0%, transparent 55%),
linear-gradient(160deg, #f5f9ff 0%, #ecf2fb 50%, #fafcff 100%);
@media (max-width: 1023px) {
justify-content: center;
padding-right: 0;
}
}
/* ---------- 远景浮尘微粒 ---------- */
.scene-motes {
position: absolute;
inset: -40px;
background-image:
radial-gradient(2px 2px at 12% 22%, rgb(30 128 223 / 30%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 28% 68%, rgb(23 44 84 / 16%) 50%, transparent 51%),
radial-gradient(2.5px 2.5px at 44% 12%, rgb(30 128 223 / 22%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 58% 44%, rgb(23 44 84 / 12%) 50%, transparent 51%),
radial-gradient(2px 2px at 72% 78%, rgb(56 189 248 / 25%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 86% 28%, rgb(23 44 84 / 14%) 50%, transparent 51%),
radial-gradient(2px 2px at 94% 62%, rgb(30 128 223 / 20%) 50%, transparent 51%),
radial-gradient(1.5px 1.5px at 6% 86%, rgb(56 189 248 / 18%) 50%, transparent 51%);
background-size: 520px 520px;
background-repeat: repeat;
animation: motes-breathe 6s ease-in-out infinite alternate;
transition: transform 0.25s ease-out;
pointer-events: none;
}
@keyframes motes-breathe {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
/* ---------- 协作汇流图 ---------- */
.scene-graph {
position: absolute;
inset: 0;
transition: transform 0.25s ease-out;
pointer-events: none;
}
.scene-graph__svg {
width: 100%;
height: 100%;
}
.trunk {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
}
.branch {
fill: none;
stroke-width: 2;
stroke-linecap: round;
animation: branch-breathe 6s ease-in-out infinite alternate;
animation-delay: var(--breathe-d, 0s);
}
@keyframes branch-breathe {
from {
opacity: 0.55;
}
to {
opacity: 1;
}
}
.travel-dot {
filter: drop-shadow(0 0 6px currentColor);
}
.wave {
fill: none;
stroke-linecap: round;
animation: wave-drift var(--wave-dur) linear infinite;
}
@keyframes wave-drift {
to {
transform: translateX(var(--wave-shift));
}
}
/* 电力场景剪影 */
.industry {
fill: none;
stroke: #424a8c;
stroke-width: 1.5;
stroke-linecap: round;
opacity: 0.22;
}
.industry__hub {
fill: #424a8c;
stroke: none;
}
.industry__spark {
fill: var(--brand);
color: var(--brand);
opacity: 0.65;
}
.merge-pulse {
fill: none;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: merge-pulse 2.6s ease-out infinite;
}
@keyframes merge-pulse {
0% {
opacity: 0.8;
transform: scale(1);
}
70%,
100% {
opacity: 0;
transform: scale(3.2);
}
}
/* 角色徽章 */
.role-chip {
position: absolute;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border: 1px solid rgb(30 35 80 / 10%);
border-radius: 999px;
font-size: 13px;
letter-spacing: 0.14em;
color: rgb(30 35 80 / 72%);
background: rgb(255 255 255 / 65%);
box-shadow: 0 6px 18px -8px rgb(23 92 171 / 22%);
backdrop-filter: blur(8px);
animation: chip-float 5.5s ease-in-out infinite alternate;
animation-delay: var(--float-d, 0s);
}
.role-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
box-shadow: 0 0 8px 1px currentcolor;
}
@keyframes chip-float {
from {
transform: translateY(-6px);
}
to {
transform: translateY(8px);
}
}
/* ---------- 品牌与文案 ---------- */
.scene-header {
position: absolute;
top: 40px;
left: 56px;
z-index: 2;
display: flex;
align-items: center;
gap: 12px;
color: #232850;
}
.scene-header__name {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.08em;
}
.scene-hero {
position: absolute;
top: 24%;
left: 6.5%;
z-index: 2;
color: #1b2050;
transition: transform 0.25s ease-out;
pointer-events: none;
@media (max-width: 1023px) {
display: none;
}
}
.scene-hero__eyebrow {
margin-bottom: 26px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 13px;
letter-spacing: 0.46em;
color: rgb(30 35 80 / 45%);
}
.scene-hero__slogan {
font-size: 64px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.14em;
text-shadow: 0 8px 32px rgb(255 255 255 / 70%);
em {
font-style: normal;
background: linear-gradient(120deg, var(--brand) 0%, #0b66c3 55%, #38bdf8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.scene-hero__comma {
color: rgb(30 35 80 / 28%);
}
.scene-hero__sub {
margin-top: 26px;
font-size: 16px;
letter-spacing: 0.22em;
color: rgb(30 35 80 / 52%);
}
.scene-footer {
position: absolute;
bottom: 26px;
left: 56px;
z-index: 2;
font-size: 12px;
letter-spacing: 0.1em;
color: rgb(30 35 80 / 32%);
}
/* ---------- 登录卡片(白玻璃质感) ---------- */
.login-card {
position: relative;
z-index: 3;
width: 420px;
padding: 44px 40px 40px;
border: 1px solid rgb(255 255 255 / 75%);
border-radius: 20px;
background: linear-gradient(168deg, rgb(255 255 255 / 82%) 0%, rgb(248 250 255 / 88%) 100%);
backdrop-filter: blur(20px) saturate(140%);
box-shadow:
0 30px 70px -24px rgb(23 92 171 / 26%),
0 0 0 1px rgb(30 35 80 / 5%),
0 1px 0 rgb(255 255 255 / 90%) inset;
}
.login-card__header {
margin-bottom: 32px;
text-align: center;
}
.login-card__logo {
display: block;
margin: 0 auto 16px;
filter: drop-shadow(0 10px 26px color-mix(in srgb, var(--brand) 35%, transparent));
}
.login-card__title {
font-size: 24px;
font-weight: 600;
letter-spacing: 0.1em;
color: #20254d;
}
.login-card__subtitle {
margin-top: 10px;
font-size: 13.5px;
letter-spacing: 0.06em;
color: rgb(30 35 80 / 48%);
}
/* 卡片内表单:浅色质感统一覆盖(作用于子模块) */
.login-card :deep(.el-input__wrapper) {
height: 48px;
padding: 0 14px;
border-radius: 10px;
background-color: rgb(30 128 223 / 5%);
box-shadow: 0 0 0 1px rgb(30 35 80 / 12%) inset;
transition:
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand) 55%, rgb(30 35 80 / 20%)) inset;
}
&.is-focus {
background-color: #fff;
box-shadow:
0 0 0 1.5px var(--brand) inset,
0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent);
}
.el-input__inner {
color: #1f244a;
caret-color: var(--brand);
&::placeholder {
color: rgb(30 35 80 / 34%);
}
}
.el-input__prefix,
.el-input__suffix {
font-size: 18px;
color: rgb(30 35 80 / 35%);
}
.el-input__prefix {
margin-right: 6px;
}
}
.login-card :deep(.el-form-item) {
margin-bottom: 22px;
}
.login-card :deep(.el-checkbox__label) {
letter-spacing: 0.04em;
color: rgb(30 35 80 / 58%);
}
/* 选中态跟随登录页品牌蓝,而非系统主题色 */
.login-card :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
border-color: var(--brand);
background-color: var(--brand);
}
.login-card :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
color: var(--brand);
}
.login-card :deep(.login-submit-button) {
position: relative;
width: 100%;
height: 48px;
margin-top: 4px;
border: none;
border-radius: 10px;
overflow: hidden;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.32em;
text-indent: 0.32em;
background: linear-gradient(135deg, var(--brand) 0%, color-mix(in srgb, var(--brand) 68%, #0a3f8f) 100%);
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--brand) 60%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
/* 流光扫过 */
&::after {
content: '';
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(100deg, transparent 0%, rgb(255 255 255 / 35%) 50%, transparent 100%);
transform: skewX(-20deg);
transition: left 0.55s ease;
}
&:hover {
transform: translateY(-1px);
filter: brightness(1.06);
box-shadow: 0 16px 32px -10px color-mix(in srgb, var(--brand) 70%, transparent);
&::after {
left: 130%;
}
}
&:active {
transform: translateY(0);
}
}
.login-card :deep(.login-back-button) {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border: 1px solid rgb(30 35 80 / 15%);
border-radius: 10px;
color: rgb(30 35 80 / 70%);
background: transparent;
&:hover {
border-color: color-mix(in srgb, var(--brand) 60%, transparent);
color: var(--brand);
background: color-mix(in srgb, var(--brand) 6%, transparent);
}
}
/* ---------- 入场动效 ---------- */
.reveal {
animation: reveal-up 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0s);
}
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

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
type="primary"
size="large"
class="login-submit-button"
:loading="authStore.loginLoading"
@click="handleSubmit"
>
{{ $t('route.login') }}
</ElButton> </ElButton>
</ElSpace>
</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" round @click="toggleLoginModule('pwd-login')"> <ElButton size="large" class="login-back-button" @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }} {{ $t('page.login.common.back') }}
</ElButton> </ElButton>
</ElSpace>
</ElForm> </ElForm>
</template> </template>
<style scoped></style> <style scoped>
.login-back-button {
width: 100%;
height: 44px;
margin-top: 14px;
margin-left: 0;
border-radius: 10px;
}
</style>

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,28 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { computed, markRaw, reactive, ref } from 'vue'; import { computed, markRaw, reactive, ref, watch } 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 {
fetchCancelOvertimeApplication,
fetchDeleteOvertimeApplication,
fetchExportOvertimeApplications, fetchExportOvertimeApplications,
fetchGetOvertimeApplicationPage fetchGetMySubordinateTree,
fetchGetOvertimeApplicationPage,
fetchGetTeamOvertimeSummary
} from '@/service/api'; } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table'; import { useUIPaginatedTable } from '@/hooks/common/table';
import { useAuth } from '@/hooks/business/auth';
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
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 {
type TeamViewContext,
type TeamViewMode,
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
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 +31,14 @@ 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';
import IconMdiDownloadOutline from '~icons/mdi/download-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 {
@@ -67,24 +73,59 @@ function transformPageResult(response: OvertimeApplicationPageResponse, pageNo:
} }
const searchParams = reactive(getInitSearchParams()); const searchParams = reactive(getInitSearchParams());
const { hasAuth } = useAuth();
const teamViewMode = ref<TeamViewMode>('self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
const teamSummaryLoading = ref(false);
const teamSummary = ref<Api.OvertimeApplication.TeamOvertimeSummary | null>(null);
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 canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const isTeamMode = computed(() => teamViewMode.value === 'team');
const isRootSelected = computed(() => Boolean(isTeamMode.value && selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (!isTeamMode.value) return '我自己';
if (!selectedSubordinateNode.value) return '--';
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
});
const teamContext = computed<TeamViewContext | null>(() => {
if (!canUseTeamDashboard.value) return null;
return {
mode: teamViewMode.value,
selectedUserId: selectedSubordinateUserId.value,
selectedUserIds:
isTeamMode.value && selectedSubordinateUserId.value && !isRootSelected.value
? [selectedSubordinateUserId.value]
: [],
isRootSelected: isRootSelected.value,
allSubordinateUserIds: allSubordinateUserIds.value,
selectedLabel: selectedTeamLabel.value
};
});
const currentApplicantIds = computed(() => {
if (!isTeamMode.value) return null;
if (isRootSelected.value) return [];
return teamContext.value?.selectedUserIds ?? [];
});
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
OvertimeApplicationPageResponse, OvertimeApplicationPageResponse,
Api.OvertimeApplication.OvertimeApplication Api.OvertimeApplication.OvertimeApplication
@@ -93,7 +134,11 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
currentPage: searchParams.pageNo, currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize pageSize: searchParams.pageSize
}, },
api: () => fetchGetOvertimeApplicationPage(searchParams), api: () =>
fetchGetOvertimeApplicationPage({
...searchParams,
applicantIds: currentApplicantIds.value
}),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10), transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => { onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1; searchParams.pageNo = params.currentPage ?? 1;
@@ -101,7 +146,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
}, },
columns: () => [ columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 }, { prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true }, ...(isTeamMode.value ? [{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true }] : []),
{ {
prop: 'overtimeDate', prop: 'overtimeDate',
label: '加班日期', label: '加班日期',
@@ -113,14 +158,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,23 +179,23 @@ 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)
}, },
{ {
prop: 'operate', prop: 'operate',
label: '操作', label: '操作',
width: 170, width: isTeamMode.value ? 140 : 170,
align: 'center', align: 'center',
fixed: 'right', fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" /> formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
@@ -164,14 +209,28 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
const actions: BusinessTableAction[] = [ const actions: BusinessTableAction[] = [
{ {
key: 'detail', key: 'detail',
label: '详情', label: '查看',
buttonType: 'primary', buttonType: 'primary',
icon: ACTION_ICON_MAP.detail, icon: ACTION_ICON_MAP.detail,
onClick: () => openDetail(row) onClick: () => openDetail(row)
} }
]; ];
if ((row.statusCode === 'rejected' || row.statusCode === 'cancelled') && row.allowEdit) { if (isTeamMode.value) {
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({
key: 'approval-record',
label: '审批记录',
buttonType: 'info',
icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openApprovalRecord(row)
});
}
return actions;
}
if (row.statusCode === 'rejected' && row.allowEdit) {
actions.push({ actions.push({
key: 'edit', key: 'edit',
label: '修改', label: '修改',
@@ -181,31 +240,13 @@ function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): Busine
}); });
} }
if (['approved', 'rejected'].includes(row.statusCode)) {
actions.push({ actions.push({
key: 'status-log', key: 'approval-record',
label: '状态日志', label: '审批记录',
buttonType: 'info', buttonType: 'info',
icon: ACTION_ICON_MAP.statusLog, icon: ACTION_ICON_MAP.approvalRecord,
onClick: () => openStatusLog(row) onClick: () => openApprovalRecord(row)
});
if (row.statusCode === 'pending') {
actions.push({
key: 'cancel',
label: '撤销',
buttonType: 'danger',
icon: ACTION_ICON_MAP.cancel,
onClick: () => openCancel(row)
});
}
if (row.statusCode === 'cancelled') {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
onClick: () => handleDelete(row)
}); });
} }
@@ -229,15 +270,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,52 +294,20 @@ function handleSubmitted() {
reloadTable(searchParams.pageNo ?? 1); reloadTable(searchParams.pageNo ?? 1);
} }
async function handleActionSubmit(reason: string | null) { function createExportParams() {
if (!currentRow.value) { const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
return; return {
} ...params,
applicantIds: currentApplicantIds.value
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({
...createExportParams(),
applicantIds: currentApplicantIds.value
});
exporting.value = false; exporting.value = false;
if (error || !blob) { if (error || !blob) {
@@ -313,10 +316,102 @@ async function handleExport() {
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`); downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
} }
async function loadSubordinateTree() {
if (!canUseTeamDashboard.value) return;
subordinateTreeLoading.value = true;
const { error, data: treeData } = await fetchGetMySubordinateTree();
subordinateTreeLoading.value = false;
subordinateTree.value = error || !treeData ? null : treeData;
selectedSubordinateUserId.value = treeData?.userId || null;
}
async function loadTeamSummary() {
if (!isRootSelected.value) {
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data: summaryData } = await fetchGetTeamOvertimeSummary();
teamSummaryLoading.value = false;
teamSummary.value = error || !summaryData ? null : summaryData;
}
async function handleTeamViewModeChange(mode: TeamViewMode) {
teamViewMode.value = mode;
if (mode === 'team') {
if (!subordinateTree.value) {
await loadSubordinateTree();
}
if (!selectedSubordinateUserId.value) {
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
}
}
await reloadTable(1);
}
watch(
() => [teamViewMode.value, selectedSubordinateUserId.value],
async () => {
if (!isTeamMode.value) return;
await reloadTable(1);
}
);
watch(
() => isRootSelected.value,
() => {
loadTeamSummary();
}
);
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="overtime-application-page">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
>
<div v-if="isRootSelected" v-loading="teamSummaryLoading" class="team-overtime-summary">
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月申请单数</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.totalApplicationCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月待审批</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.pendingCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已通过</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.approvedCount ?? 0 }}</strong>
</div>
<div class="team-overtime-summary__item">
<span class="team-overtime-summary__label">本月已退回</span>
<strong class="team-overtime-summary__value">{{ teamSummary?.rejectedCount ?? 0 }}</strong>
</div>
</div>
</TeamContextPanel>
<div class="overtime-application-page__content" :class="{ 'overtime-application-page__content--team': isTeamMode }">
<div v-if="canUseTeamDashboard && isTeamMode" class="overtime-application-page__sidebar">
<SubordinateSelector
v-model:selected-user-id="selectedSubordinateUserId"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
<div class="overtime-application-page__main">
<OvertimeApplicationSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" /> <OvertimeApplicationSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body"> <ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
@@ -334,7 +429,7 @@ async function handleExport() {
</template> </template>
导出 导出
</ElButton> </ElButton>
<ElButton plain type="primary" @click="openAdd"> <ElButton v-if="!isTeamMode" plain type="primary" @click="openAdd">
<template #icon> <template #icon>
<icon-ic-round-plus class="text-icon" /> <icon-ic-round-plus class="text-icon" />
</template> </template>
@@ -363,6 +458,8 @@ async function handleExport() {
/> />
</div> </div>
</ElCard> </ElCard>
</div>
</div>
<OvertimeApplicationOperateDialog <OvertimeApplicationOperateDialog
v-model:visible="operateVisible" v-model:visible="operateVisible"
@@ -373,18 +470,47 @@ 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>
<style scoped> <style scoped>
.overtime-application-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.overtime-application-page__content {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.overtime-application-page__main {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
}
@media (min-width: 1280px) {
.overtime-application-page__content--team {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
}
.overtime-application-page__sidebar {
min-height: 0;
}
}
:deep(.overtime-application__reason-link) { :deep(.overtime-application__reason-link) {
max-width: 100%; max-width: 100%;
padding: 0; padding: 0;
@@ -398,4 +524,39 @@ 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;
}
.team-overtime-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.team-overtime-summary__item {
display: grid;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.team-overtime-summary__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.team-overtime-summary__value {
color: var(--el-text-color-primary);
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
</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,70 @@
export type TeamViewMode = 'self' | 'team';
export interface TeamSelectableUser {
userId: string;
userNickname: string;
}
export interface TeamSelectionState {
mode: TeamViewMode;
selectedUserId: string | null;
selectedUserIds: string[] | null;
isRootSelected: boolean;
}
export interface TeamViewContext extends TeamSelectionState {
allSubordinateUserIds: string[];
selectedLabel: string;
}
export function resolveTeamQueryUserIds(context: TeamViewContext | null | undefined): string[] | null {
if (!context || context.mode !== 'team') {
return null;
}
if (context.isRootSelected) {
return [...context.allSubordinateUserIds];
}
return context.selectedUserIds ? [...context.selectedUserIds] : [];
}
export function collectSubordinateUserIds(root: Api.SystemManage.MySubordinateTreeNode | null | undefined): string[] {
if (!root) return [];
const ids: string[] = [];
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
nodes?.forEach(node => {
ids.push(node.userId);
walk(node.children ?? null);
});
};
walk(root.children ?? null);
return ids;
}
export function findSubordinateNode(
root: Api.SystemManage.MySubordinateTreeNode | null | undefined,
userId: string | null
): Api.SystemManage.MySubordinateTreeNode | null {
if (!root || !userId) return null;
if (root.userId === userId) {
return root;
}
const stack = [...(root.children ?? [])];
while (stack.length) {
const current = stack.shift()!;
if (current.userId === userId) {
return current;
}
if (current.children?.length) {
stack.push(...current.children);
}
}
return null;
}

View File

@@ -0,0 +1,367 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { fetchGetMySubordinateTree, fetchGetProjectReportOwnerProjectOptions } from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import SubordinateSelector from '@/components/custom/subordinate-selector.vue';
import TeamContextPanel from '@/components/custom/team-context-panel.vue';
import {
type TeamViewContext,
type TeamViewMode,
collectSubordinateUserIds,
findSubordinateNode
} from '../shared/team-dashboard';
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,
type WorkReportRow,
type WorkReportType,
getWorkReportTypeDisplayLabel
} 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 teamViewMode = ref<TeamViewMode>('self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
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 canUseTeamDashboard = computed(() => hasAuth('project:work-report:team-dashboard'));
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
const selectedSubordinateNode = computed(() =>
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
);
const isRootSelected = computed(() => Boolean(selectedSubordinateNode.value?.isRoot));
const selectedTeamLabel = computed(() => {
if (teamViewMode.value === 'self') return '我自己';
if (!selectedSubordinateNode.value) return '--';
return selectedSubordinateNode.value.isRoot ? '全部下属' : selectedSubordinateNode.value.userNickname;
});
const teamContext = computed<TeamViewContext | null>(() => {
if (!canUseTeamDashboard.value) return null;
return {
mode: teamViewMode.value,
selectedUserId: selectedSubordinateUserId.value,
selectedUserIds:
teamViewMode.value === 'team' && selectedSubordinateUserId.value && !isRootSelected.value
? [selectedSubordinateUserId.value]
: [],
isRootSelected: teamViewMode.value === 'team' && isRootSelected.value,
allSubordinateUserIds: allSubordinateUserIds.value,
selectedLabel: selectedTeamLabel.value
};
});
/** 项目选项是否加载成功(用于项目半月报列表内部判断) */
const projectOptionsLoaded = ref(false);
const visibleTabs = computed<Array<{ label: string; name: WorkReportType }>>(() => {
const isTeamReportMode = teamViewMode.value === 'team';
const tabs: Array<{ label: string; name: WorkReportType }> = [
{ label: getWorkReportTypeDisplayLabel('weekly', isTeamReportMode), name: 'weekly' },
{ label: getWorkReportTypeDisplayLabel('monthly', isTeamReportMode), name: 'monthly' }
];
if (canShowProjectTab.value) {
tabs.push({ label: getWorkReportTypeDisplayLabel('project', isTeamReportMode), 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;
}
async function loadSubordinateTree() {
if (!canUseTeamDashboard.value) return;
subordinateTreeLoading.value = true;
const { error, data } = await fetchGetMySubordinateTree();
subordinateTreeLoading.value = false;
subordinateTree.value = error || !data ? null : data;
selectedSubordinateUserId.value = data?.userId || null;
}
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;
}
async function handleTabChange(tab: WorkReportType) {
activeTab.value = tab;
await nextTick();
await 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;
}
async function handleTeamViewModeChange(mode: TeamViewMode) {
teamViewMode.value = mode;
if (mode === 'team') {
if (!subordinateTree.value) {
await loadSubordinateTree();
}
if (!selectedSubordinateUserId.value) {
selectedSubordinateUserId.value = subordinateTree.value?.userId || null;
}
}
await nextTick();
await getListRef(activeTab.value)?.reload(1);
}
watch(selectedSubordinateUserId, async (currentUserId, previousUserId) => {
if (!canUseTeamDashboard.value || teamViewMode.value !== 'team') return;
if (!currentUserId || currentUserId === previousUserId) return;
await nextTick();
await getListRef(activeTab.value)?.reload(1);
});
onMounted(async () => {
await loadProjectOptions();
if (canUseTeamDashboard.value) {
await loadSubordinateTree();
}
});
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="work-report-page-shell__sidebar"
:class="{ 'work-report-page-shell__sidebar--team': canUseTeamDashboard && teamViewMode === 'team' }"
>
<WorkReportTabs
class="work-report-page-shell__sidebar-card"
:active-tab="activeTab"
:tabs="visibleTabs"
@update:active-tab="handleTabChange"
/>
<SubordinateSelector
v-if="canUseTeamDashboard && teamViewMode === 'team'"
v-model:selected-user-id="selectedSubordinateUserId"
class="work-report-page-shell__sidebar-card"
:loading="subordinateTreeLoading"
:data="subordinateTree"
/>
</div>
<!-- 右侧搜索区 + 列表区 -->
<div class="flex-col-stretch gap-16px xl:min-h-0">
<TeamContextPanel
v-if="canUseTeamDashboard"
v-model:mode="teamViewMode"
:loading="subordinateTreeLoading"
:selected-label="selectedTeamLabel"
:subordinate-count="subordinateTree?.subordinateCount || 0"
@update:mode="handleTeamViewModeChange"
/>
<WeeklyReportIndex
v-show="activeTab === 'weekly'"
ref="weeklyRef"
class="flex-1-hidden"
:team-context="teamContext"
@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"
:team-context="teamContext"
@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"
:team-context="teamContext"
: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%;
}
.work-report-page-shell__sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (min-width: 1280px) {
.work-report-page-shell__sidebar {
min-height: 0;
}
.work-report-page-shell__sidebar--team {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.work-report-page-shell__sidebar-card {
min-height: 0;
}
}
</style>

View File

@@ -0,0 +1,440 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteMonthlyReport,
fetchExportMonthlyReportContent,
fetchGetMonthlyReportPage,
fetchGetTeamReportSummary,
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 { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
import {
type WorkReportRow,
createMonthlySearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
getWorkReportTypeDisplayLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
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';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'MonthlyWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
}>();
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 teamSummaryLoading = ref(false);
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
export: markRaw(IconMdiDownloadOutline)
};
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentTeamReporterIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('monthly', isTeamMode.value));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetMonthlyReportPage>>,
Api.WorkReport.Monthly.MonthlyReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () =>
fetchGetMonthlyReportPage({
...searchParams,
reporterIds: currentTeamReporterIds.value
}),
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 },
...(isTeamMode.value ? [{ prop: 'reporterName', label: '提交人', minWidth: 100, showOverflowTooltip: true }] : []),
{ 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: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('monthly', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate
})
);
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 (isTeamMode.value) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'success',
icon: ACTION_ICON_MAP.export,
onClick: () =>
exportReportContent(
{
exportAll: false,
ids: [row.id]
},
1
)
});
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;
}
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);
await loadTeamSummary();
}
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,
reporterIds: currentTeamReporterIds.value
};
}
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();
}
async function loadTeamSummary() {
if (!isTeamRootSelected.value) {
teamSummaryLoading.value = false;
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'monthly',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
defineExpose({ reload });
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<MonthlyReportSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="monthly"
:period-key="summaryPeriod.periodKey"
:period-label="formatPeriod(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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">{{ reportTitle }}</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-if="!isTeamMode"
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,456 @@
<script setup lang="tsx">
/* eslint-disable no-void */
import { computed, markRaw, reactive, ref } from 'vue';
import { ElMessageBox, ElTag } from 'element-plus';
import {
fetchDeleteProjectReport,
fetchExportProjectReportContent,
fetchGetProjectReportPage,
fetchGetTeamReportSummary,
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 { type TeamViewContext, resolveTeamQueryUserIds } from '@/views/personal-center/shared/team-dashboard';
import {
type WorkReportRow,
createProjectSearchParams,
createWorkReportContentExportFallbackName,
downloadBlob,
formatDateTime,
formatEmptyText,
formatPeriod,
getWorkReportStatusLabel,
getWorkReportTypeDisplayLabel,
resolveExportFilename,
resolveWorkReportStatusTagType,
transformWorkReportPage
} from '../shared/types';
import { resolveWorkReportSummaryPeriod } from '../shared/utils';
import TeamReportSummary from '../shared/components/team-report-summary.vue';
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';
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
defineOptions({ name: 'ProjectWorkReportIndex' });
const props = defineProps<{
teamContext?: TeamViewContext | null;
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 teamSummaryLoading = ref(false);
const teamSummary = ref<Api.WorkReport.Common.TeamReportSummary | null>(null);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
edit: markRaw(IconMdiPencilOutline),
submit: markRaw(IconMdiSendOutline),
delete: markRaw(IconMdiDeleteOutline),
approvalRecord: markRaw(IconMdiFileDocumentCheckOutline),
export: markRaw(IconMdiDownloadOutline)
};
const isTeamMode = computed(() => props.teamContext?.mode === 'team');
const isTeamRootSelected = computed(() => Boolean(isTeamMode.value && props.teamContext?.isRootSelected));
const currentProjectOwnerIds = computed(() => resolveTeamQueryUserIds(props.teamContext));
const reportTitle = computed(() => getWorkReportTypeDisplayLabel('project', isTeamMode.value));
const table = useUIPaginatedTable<
Awaited<ReturnType<typeof fetchGetProjectReportPage>>,
Api.WorkReport.Project.ProjectReport
>({
paginationProps: { currentPage: searchParams.pageNo, pageSize: searchParams.pageSize },
api: () =>
fetchGetProjectReportPage({
...searchParams,
projectOwnerIds: currentProjectOwnerIds.value
}),
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 },
...(isTeamMode.value
? [{ prop: 'projectOwnerName', label: '提交人', minWidth: 100, showOverflowTooltip: true }]
: []),
{ prop: 'projectName', label: '项目名称', minWidth: 200, showOverflowTooltip: true },
{ prop: 'periodLabel', label: '半月周期', minWidth: 120, formatter: row => formatPeriod(row) },
{ 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: isTeamMode.value ? 140 : 180,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
const summaryPeriod = computed(() =>
resolveWorkReportSummaryPeriod('project', {
currentRow: table.data.value[0],
periodRange: searchParams.periodStartDate,
flag: searchParams.flag
})
);
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 (isTeamMode.value) {
actions.push({
key: 'export',
label: '导出',
buttonType: 'success',
icon: ACTION_ICON_MAP.export,
onClick: () =>
exportReportContent(
{
exportAll: false,
ids: [row.id]
},
1
)
});
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;
}
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);
await loadTeamSummary();
}
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,
projectOwnerIds: currentProjectOwnerIds.value
};
}
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();
}
async function loadTeamSummary() {
if (!isTeamRootSelected.value) {
teamSummaryLoading.value = false;
teamSummary.value = null;
return;
}
teamSummaryLoading.value = true;
const { error, data } = await fetchGetTeamReportSummary({
reportType: 'project',
periodKey: summaryPeriod.value.periodKey
});
teamSummaryLoading.value = false;
teamSummary.value = error || !data ? null : data;
}
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"
/>
<TeamReportSummary
v-if="isTeamRootSelected"
report-type="project"
:period-key="summaryPeriod.periodKey"
:period-label="formatPeriod(summaryPeriod)"
:loading="teamSummaryLoading"
:summary="teamSummary"
@reminded="loadTeamSummary"
/>
<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">{{ reportTitle }}</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-if="!isTeamMode"
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>

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