fix(加班申请): 去掉撤销相关的状态和动作。

feat(工作报告): 开发工作报告功能
This commit is contained in:
dk
2026-06-11 10:56:24 +08:00
parent 2e369b23a9
commit d53a8dfae5
56 changed files with 14312 additions and 2910 deletions

View File

@@ -131,16 +131,23 @@ export function setupElegantRouter() {
order: 1,
keepAlive: true
},
'personal-center_my-weekly': {
icon: 'mdi:calendar-week-outline',
order: 2,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
'personal-center_work-report': {
icon: 'mdi:file-chart-outline',
order: 3,
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': {
icon: 'mdi:trophy-outline',
order: 4,

View File

@@ -6,7 +6,7 @@
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 23,
"total": 22,
"items": [
{
"name": "product_list",
@@ -306,15 +306,15 @@
"source": "generated"
},
{
"name": "personal-center_my-weekly",
"path": "/personal-center/my-weekly",
"component": "view.personal-center_my-weekly",
"title": "我的周报",
"routeTitle": "personal-center_my-weekly",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"name": "personal-center_work-report",
"path": "/personal-center/work-report",
"component": "view.personal-center_work-report",
"title": "工作报告",
"routeTitle": "personal-center_work-report",
"i18nKey": "route.personal-center_work-report",
"icon": "mdi:file-chart-outline",
"localIcon": null,
"order": 1,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
@@ -323,44 +323,11 @@
"redirect": null,
"props": null,
"meta": {
"title": "我的周报",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"title": "工作报告",
"i18nKey": "route.personal-center_work-report",
"icon": "mdi:file-chart-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-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,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
@@ -380,7 +347,7 @@
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 3,
"order": 4,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
@@ -393,7 +360,7 @@
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 3,
"order": 4,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
@@ -413,7 +380,7 @@
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"order": 5,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
@@ -426,40 +393,7 @@
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application",
"component": "view.personal-center_overtime-application",
"title": "加班申请",
"routeTitle": "personal-center_overtime-application",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "加班申请",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"order": 5,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
@@ -503,6 +437,39 @@
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application",
"component": "view.personal-center_overtime-application",
"title": "加班申请",
"routeTitle": "personal-center_overtime-application",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "加班申请",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "system_user",
"path": "/system/user",

2431
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -17,6 +17,7 @@ export type StatusDomain =
| 'productRequirement'
| 'projectRequirement'
| 'workOrder'
| 'workReport'
| 'personalItem'
| 'overtimeApplication';
@@ -80,6 +81,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
},
// 工单(待补全)
workOrder: {},
// 工作报告
workReport: {
draft: 'info',
pending_approval: 'warning',
approved: 'success',
rejected: 'danger'
},
// 个人事项
personalItem: {
pending: 'info',
@@ -91,8 +99,7 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
overtimeApplication: {
pending: 'warning',
approved: 'success',
rejected: 'danger',
cancelled: 'info'
rejected: 'danger'
}
};

View File

@@ -169,8 +169,10 @@ const local: App.I18n.Schema = {
'personal-center': 'Personal Center',
'personal-center_my-profile': 'My Profile',
'personal-center_my-item': 'My Items',
'personal-center_my-weekly': 'My Weekly Report',
'personal-center_my-monthly': 'My Monthly Report',
'personal-center_work-report': 'Work Report',
'personal-center_work-report_weekly': 'Weekly Report',
'personal-center_work-report_monthly': 'Monthly Report',
'personal-center_work-report_project': 'Project Fortnightly Report',
'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application',
'personal-center_overtime-application': 'Overtime Application',

View File

@@ -169,8 +169,10 @@ const local: App.I18n.Schema = {
'personal-center': '个人中心',
'personal-center_my-profile': '个人信息',
'personal-center_my-item': '我的事项',
'personal-center_my-weekly': '我的周报',
'personal-center_my-monthly': '我的月报',
'personal-center_work-report': '工作报告',
'personal-center_work-report_weekly': '个人周报',
'personal-center_work-report_monthly': '个人月报',
'personal-center_work-report_project': '项目半月报',
'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请',
'personal-center_overtime-application': '加班申请',

View File

@@ -27,12 +27,14 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
"personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
"personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
"personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
"personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"),

View File

@@ -185,18 +185,6 @@ export const generatedRoutes: GeneratedRoute[] = [
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',
path: '/personal-center/my-performance',
@@ -221,18 +209,6 @@ export const generatedRoutes: GeneratedRoute[] = [
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',
path: '/personal-center/overtime-application',
@@ -256,6 +232,53 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 7,
keepAlive: true
}
},
{
name: 'personal-center_work-report',
path: '/personal-center/work-report',
component: 'view.personal-center_work-report',
meta: {
title: 'personal-center_work-report',
i18nKey: 'route.personal-center_work-report',
icon: 'mdi:file-chart-outline',
order: 3,
keepAlive: true
},
children: [
{
name: 'personal-center_work-report_monthly',
path: '/personal-center/work-report/monthly',
component: 'view.personal-center_work-report_monthly',
meta: {
title: 'personal-center_work-report_monthly',
i18nKey: 'route.personal-center_work-report_monthly',
hideInMenu: true,
activeMenu: 'personal-center_work-report'
}
},
{
name: 'personal-center_work-report_project',
path: '/personal-center/work-report/project',
component: 'view.personal-center_work-report_project',
meta: {
title: 'personal-center_work-report_project',
i18nKey: 'route.personal-center_work-report_project',
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'
}
}
]
}
]
},

View File

@@ -182,12 +182,14 @@ const routeMap: RouteMap = {
"personal-center": "/personal-center",
"personal-center_my-application": "/personal-center/my-application",
"personal-center_my-item": "/personal-center/my-item",
"personal-center_my-monthly": "/personal-center/my-monthly",
"personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_overtime-application": "/personal-center/overtime-application",
"personal-center_pending-approval": "/personal-center/pending-approval",
"personal-center_work-report": "/personal-center/work-report",
"personal-center_work-report_monthly": "/personal-center/work-report/monthly",
"personal-center_work-report_project": "/personal-center/work-report/project",
"personal-center_work-report_weekly": "/personal-center/work-report/weekly",
"product": "/product",
"product_dashboard": "/product/dashboard",
"product_list": "/product/list",

View File

@@ -10,3 +10,4 @@ export * from './project';
export * from './project-shared';
export * from './route';
export * from './system-manage';
export * from './work-report';

View File

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

View File

@@ -0,0 +1,866 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const WORK_REPORT_PREFIX = `${WEB_SERVICE_PREFIX}/project/work-reports`;
const WEEKLY_PREFIX = `${WORK_REPORT_PREFIX}/weekly`;
const MONTHLY_PREFIX = `${WORK_REPORT_PREFIX}/monthly`;
const PROJECT_PREFIX = `${WORK_REPORT_PREFIX}/project`;
type StringIdResponse = string | number;
type MaybeStringIdResponse = string | number | null | undefined;
type PageResponse<T> = {
total: number | string;
list: T[];
};
type ReviewItemResponse = Omit<Api.WorkReport.Common.PersonalReportReviewItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type PlanItemResponse = Omit<Api.WorkReport.Common.PersonalReportPlanItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyTravelSegmentResponse = Omit<Api.WorkReport.Weekly.WeeklyReportTravelSegment, 'id'> & {
id?: MaybeStringIdResponse;
};
type WeeklyReportResponse = Omit<
Api.WorkReport.Weekly.WeeklyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems' | 'travelSegments'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
travelSegments?: WeeklyTravelSegmentResponse[] | null;
};
type MonthlyReportResponse = Omit<
Api.WorkReport.Monthly.MonthlyReport,
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems'
> & {
id: StringIdResponse;
reporterId: StringIdResponse;
supervisorUserId: StringIdResponse;
reviewItems?: ReviewItemResponse[] | null;
planItems?: PlanItemResponse[] | null;
};
type MemberSnapshotResponse = Omit<Api.WorkReport.Project.WorkReportMemberSnapshot, 'userId'> & {
userId: StringIdResponse;
};
type ProjectReportItemResponse = Omit<Api.WorkReport.Project.ProjectReportItem, 'id'> & {
id?: MaybeStringIdResponse;
};
type ProjectReportResponse = Omit<
Api.WorkReport.Project.ProjectReport,
'id' | 'projectId' | 'projectOwnerId' | 'projectMemberSnapshot' | 'supervisorUserId' | 'currentItems' | 'nextItems'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectOwnerId: StringIdResponse;
projectMemberSnapshot?: MemberSnapshotResponse[] | null;
supervisorUserId: StringIdResponse;
currentItems?: ProjectReportItemResponse[] | null;
nextItems?: ProjectReportItemResponse[] | null;
};
type ApprovalRecordResponse = Omit<
Api.WorkReport.Common.WorkReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type MonthlyApprovalRecordResponse = Omit<
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
'id' | 'statusLogId' | 'auditorUserId'
> & {
id: StringIdResponse;
statusLogId: StringIdResponse;
auditorUserId: StringIdResponse;
};
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
id: StringIdResponse;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeApprovalConclusion(value: unknown) {
const conclusion = String(value || '')
.trim()
.toLowerCase();
if (conclusion === 'approve') return 'approved';
if (conclusion === 'reject') return 'rejected';
return conclusion;
}
function normalizeDateText(value: unknown) {
if (value === null || value === undefined) return undefined;
const text = String(value).trim();
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
if (commaDateMatch) {
const [, year, month, day] = commaDateMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return text || undefined;
}
function normalizeTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function sumWorkHours(items: Array<{ workHours?: number | string | null }> = []) {
return items.reduce((sum, item) => {
const value = Number(item.workHours ?? 0);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
}
function normalizeReportTotalWorkHours(
totalWorkHours: number | string | null | undefined,
fallbackTotalWorkHours: number
) {
const normalizedTotal = Number(totalWorkHours ?? 0);
if (
(totalWorkHours === null ||
totalWorkHours === undefined ||
totalWorkHours === '' ||
(Number.isFinite(normalizedTotal) && normalizedTotal === 0)) &&
fallbackTotalWorkHours > 0
) {
return fallbackTotalWorkHours;
}
return totalWorkHours ?? 0;
}
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
query.append(key, String(value));
}
function appendArray(query: URLSearchParams, key: string, values?: Array<string | null | undefined> | null) {
values?.forEach(value => appendValue(query, key, value));
}
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
const query = new URLSearchParams();
appendValue(query, 'pageNo', params.pageNo ?? 1);
appendValue(query, 'pageSize', params.pageSize ?? 10);
appendValue(query, 'keyword', params.keyword);
appendValue(query, 'statusCode', params.statusCode);
appendValue(query, 'supervisorName', params.supervisorName);
appendArray(query, 'periodStartDate', params.periodStartDate);
appendArray(query, 'submitTime', params.submitTime);
return query;
}
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
return query.toString();
}
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
return createBasePageQuery(params).toString();
}
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
const query = createBasePageQuery(params);
appendValue(query, 'projectId', params.projectId);
appendValue(query, 'flag', params.flag);
return query.toString();
}
function normalizeReviewItem(item: ReviewItemResponse): Api.WorkReport.Common.PersonalReportReviewItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizePlanItem(item: PlanItemResponse): Api.WorkReport.Common.PersonalReportPlanItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeWeeklyTravelSegment(
item: WeeklyTravelSegmentResponse
): Api.WorkReport.Weekly.WeeklyReportTravelSegment {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined,
startDate: normalizeDateText(item.startDate),
endDate: normalizeDateText(item.endDate)
};
}
function normalizeWeeklyReport(response: WeeklyReportResponse): Api.WorkReport.Weekly.WeeklyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
isBusinessTrip: normalizeBooleanFlag(response.isBusinessTrip),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? [],
travelSegments: response.travelSegments?.map(normalizeWeeklyTravelSegment) ?? []
};
}
function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport.Monthly.MonthlyReport {
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
reporterId: normalizeStringId(response.reporterId),
supervisorUserId: normalizeStringId(response.supervisorUserId),
reporterDeptName: response.reporterDeptName ?? null,
reporterPostName: response.reporterPostName ?? null,
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
planItems: response.planItems?.map(normalizePlanItem) ?? []
};
}
function normalizeMemberSnapshot(item: MemberSnapshotResponse): Api.WorkReport.Project.WorkReportMemberSnapshot {
return {
...item,
userId: normalizeStringId(item.userId)
};
}
function normalizeProjectReportItem(item: ProjectReportItemResponse): Api.WorkReport.Project.ProjectReportItem {
return {
...item,
id: normalizeNullableStringId(item.id) ?? undefined
};
}
function normalizeProjectReport(response: ProjectReportResponse): Api.WorkReport.Project.ProjectReport {
const fallbackTotalWorkHours = sumWorkHours(response.currentItems ?? []);
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
projectOwnerId: normalizeStringId(response.projectOwnerId),
projectMemberSnapshot: response.projectMemberSnapshot?.map(normalizeMemberSnapshot) ?? [],
supervisorUserId: normalizeStringId(response.supervisorUserId),
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
submitTime: response.submitTime ?? null,
currentItems: response.currentItems?.map(normalizeProjectReportItem) ?? [],
nextItems: response.nextItems?.map(normalizeProjectReportItem) ?? []
};
}
function normalizeApprovalRecord(response: ApprovalRecordResponse): Api.WorkReport.Common.WorkReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeMonthlyApprovalRecord(
response: MonthlyApprovalRecordResponse
): Api.WorkReport.Monthly.MonthlyReportApprovalRecord {
return {
...response,
id: normalizeStringId(response.id),
statusLogId: normalizeStringId(response.statusLogId),
auditorUserId: normalizeStringId(response.auditorUserId),
conclusion: normalizeApprovalConclusion(response.conclusion),
opinion: response.opinion ?? null
};
}
function normalizeProjectOption(
response: ProjectOptionResponse
): Api.WorkReport.Project.ProjectReportOwnerProjectOption {
return {
...response,
id: normalizeStringId(response.id)
};
}
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
return {
total: normalizeTotal(data.total),
list: data.list.map(mapper)
};
}
function toStatusActionRequest(data: Api.WorkReport.Common.StatusActionParams = {}) {
return {
reason: data.reason?.trim() || undefined
};
}
function toPersonalReviewItems(items: Api.WorkReport.Common.PersonalReportReviewItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
contentText: item.contentText?.trim() || '',
contentJson: item.contentJson ?? null,
reflectionText: item.reflectionText?.trim() || ''
}));
}
function toPersonalPlanItems(items: Api.WorkReport.Common.PersonalReportPlanItem[] = []) {
return items.map((item, index) => ({
itemNumber: item.itemNumber ?? index + 1,
itemTitle: item.itemTitle?.trim() || '',
targetText: item.targetText?.trim() || '',
targetJson: item.targetJson ?? null,
supportNeed: item.supportNeed?.trim() || ''
}));
}
function toWeeklySaveRequest(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
isBusinessTrip: data.isBusinessTrip,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems),
travelSegments: data.isBusinessTrip
? data.travelSegments.map((item, index) => ({
sort: item.sort ?? index + 1,
startDate: item.startDate || undefined,
endDate: item.endDate || undefined,
travelDays: item.travelDays ?? 0,
location: item.location?.trim() || ''
}))
: []
};
}
function toMonthlySaveRequest(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
return {
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
reviewItems: toPersonalReviewItems(data.reviewItems),
planItems: toPersonalPlanItems(data.planItems)
};
}
function toProjectItems(items: Api.WorkReport.Project.ProjectReportItem[] = []) {
return items.map(item => ({
itemTitle: item.itemTitle?.trim() || '',
workHours: item.workHours ?? 0,
priorityCode: item.priorityCode || undefined,
progressRate: item.progressRate ?? 0
}));
}
function toProjectSaveRequest(data: Api.WorkReport.Project.ProjectReportSaveParams) {
return {
projectId: data.projectId,
periodKey: data.periodKey,
periodLabel: data.periodLabel,
periodStartDate: data.periodStartDate,
periodEndDate: data.periodEndDate,
flag: data.flag,
projectStatusDesc: data.projectStatusDesc?.trim() || '',
projectProgressPlan: data.projectProgressPlan?.trim() || '',
projectKeyPoints: data.projectKeyPoints?.trim() || '',
projectProblems: data.projectProblems?.trim() || '',
currentItems: toProjectItems(data.currentItems),
nextItems: toProjectItems(data.nextItems)
};
}
export async function fetchGetWorkReportStatusDict() {
const result = await request<Api.WorkReport.Common.WorkReportStatusDict[]>({
...safeJsonRequestConfig,
url: `${WORK_REPORT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
}
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/page?${query}` : `${WEEKLY_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportApprovalPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
const query = createWeeklyPageQuery(params);
const result = await request<PageResponse<WeeklyReportResponse>>({
...safeJsonRequestConfig,
url: query ? `${WEEKLY_PREFIX}/approval-page?${query}` : `${WEEKLY_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
mapPage(data, normalizeWeeklyReport)
);
}
export async function fetchGetWeeklyReportDetail(id: string) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchInitWeeklyReport() {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/init`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function fetchPreviewWeeklyReportDefaultDraft(
params: Api.WorkReport.Weekly.WeeklyReportDefaultDraftParams
) {
const result = await request<WeeklyReportResponse>({
...safeJsonRequestConfig,
url: `${WEEKLY_PREFIX}/default-draft`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
}
export async function 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 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 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 { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const REQUEST_TIMEOUT = 15 * 1000;
export const request = withDedupe(
createFlatRequest(
{
baseURL,
timeout: REQUEST_TIMEOUT,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
}
@@ -126,6 +128,10 @@ export const request = withDedupe(
let message = error.message;
let backendErrorCode = '';
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
message = '请求超时,请稍后重试';
}
// 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message;

View File

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

View File

@@ -5,9 +5,9 @@ declare namespace Api {
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 {
id: string;
@@ -59,19 +59,15 @@ declare namespace Api {
reason?: string | null;
}
interface OvertimeApplicationStatusLog {
interface OvertimeApplicationApprovalRecord {
id: string;
applicationId: string;
actionType: OvertimeApplicationActionType;
fromStatus?: string | null;
toStatus: string;
reason?: string | null;
operatorUserId: string;
operatorName: string;
applicantNameSnapshot: string;
overtimeDateSnapshot: string;
overtimeDurationSnapshot: string;
remark?: string | null;
overtimeApplicationId: string;
statusLogId: string;
approvalRound: number;
conclusion: string;
opinion?: string | null;
auditorUserId: string;
auditorName: string;
createTime: string;
}

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

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

View File

@@ -36,12 +36,14 @@ declare module "@elegant-router/types" {
"personal-center": "/personal-center";
"personal-center_my-application": "/personal-center/my-application";
"personal-center_my-item": "/personal-center/my-item";
"personal-center_my-monthly": "/personal-center/my-monthly";
"personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_overtime-application": "/personal-center/overtime-application";
"personal-center_pending-approval": "/personal-center/pending-approval";
"personal-center_work-report": "/personal-center/work-report";
"personal-center_work-report_monthly": "/personal-center/work-report/monthly";
"personal-center_work-report_project": "/personal-center/work-report/project";
"personal-center_work-report_weekly": "/personal-center/work-report/weekly";
"product": "/product";
"product_dashboard": "/product/dashboard";
"product_list": "/product/list";
@@ -143,12 +145,14 @@ declare module "@elegant-router/types" {
| "metrics_worktime"
| "personal-center_my-application"
| "personal-center_my-item"
| "personal-center_my-monthly"
| "personal-center_my-performance"
| "personal-center_my-profile"
| "personal-center_my-weekly"
| "personal-center_overtime-application"
| "personal-center_pending-approval"
| "personal-center_work-report"
| "personal-center_work-report_monthly"
| "personal-center_work-report_project"
| "personal-center_work-report_weekly"
| "product_dashboard"
| "product_list"
| "product_requirement"

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import dayjs from 'dayjs';
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 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 confirmText = computed(() => {
if (isCommonApprove.value) return '确认提交';
if (props.actionType === 'approve') return '通过';
return '退回';
});
const confirmDisabled = computed(() => isCommonApprove.value && !commonApprovalModel.conclusion);
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);
}
});
function handleSubmit() {
if (isCommonApprove.value) {
if (!commonApprovalModel.conclusion) {
window.$message?.warning('请选择审批结论');
return;
}
emit(
'submit',
{
reason: commonApprovalModel.opinion || (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>
<div class="audit-field">
<label>审批意见</label>
<ElInput v-model="commonApprovalModel.opinion" type="textarea" :rows="3" placeholder="请输入审批意见" />
</div>
</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,173 @@
<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,
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
);
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="报告周期">{{ formatPeriod(detail) }}</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,564 @@
<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,
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);
}
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) {
if (confirmOverwrite) {
try {
await ElMessageBox.confirm('重新拉取默认稿会覆盖当前已编辑内容,是否继续?', '覆盖确认', {
type: 'warning',
confirmButtonText: '继续',
cancelButtonText: '取消'
});
} catch {
return;
}
}
const period = {
periodKey: activeModel.value.periodKey,
periodLabel: activeModel.value.periodLabel,
periodStartDate: activeModel.value.periodStartDate,
periodEndDate: activeModel.value.periodEndDate
};
let result;
if (props.reportType === 'weekly') {
result = await fetchPreviewWeeklyReportDefaultDraft(period);
} else if (props.reportType === 'monthly') {
result = await fetchPreviewMonthlyReportDefaultDraft(period);
} else {
result = await fetchPreviewProjectReportDefaultDraft(projectModel.projectId, {
...period,
flag: projectModel.flag
});
}
if (result.error || !result.data) return;
if (props.reportType === 'weekly') {
weeklyModel.reviewItems = normalizeReviewItems((result.data as Api.WorkReport.Weekly.WeeklyReport).reviewItems);
weeklyModel.planItems = normalizePlanItems((result.data as Api.WorkReport.Weekly.WeeklyReport).planItems);
}
if (props.reportType === 'monthly') {
monthlyModel.reviewItems = normalizeReviewItems((result.data as Api.WorkReport.Monthly.MonthlyReport).reviewItems);
monthlyModel.planItems = normalizePlanItems((result.data as Api.WorkReport.Monthly.MonthlyReport).planItems);
}
if (props.reportType === 'project') {
projectModel.currentItems = normalizeProjectItems(
(result.data as Api.WorkReport.Project.ProjectReport).currentItems
);
projectModel.nextItems = normalizeProjectItems((result.data as Api.WorkReport.Project.ProjectReport).nextItems);
}
}
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,123 @@
<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"
@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;
}
: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) {
flex: 1 0 auto;
min-height: 100%;
box-sizing: border-box;
}
.work-report-page-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import SharedWorkReportApprovalRecordDialog from '../../shared/components/approval-record-dialog.vue';
import type { WorkReportRow } from '../../shared/types';
defineOptions({ name: 'WeeklyReportApprovalRecordDialog' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportApprovalRecordDialog v-model:visible="visible" report-type="weekly" :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: 'WeeklyReportDetailPage' });
const visible = defineModel<boolean>('visible', { default: false });
defineProps<{
rowData?: WorkReportRow | null;
}>();
</script>
<template>
<SharedWorkReportDetailDialog v-model:visible="visible" report-type="weekly" :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: 'WeeklyReportSearch' });
defineProps<{
projectOptions?: Api.WorkReport.Project.ProjectReportOwnerProjectOption[];
}>();
const model = defineModel<Api.WorkReport.Weekly.WeeklyReportSearchParams>('model', { required: true });
const emit = defineEmits<{
(e: 'reset'): void;
(e: 'search'): void;
}>();
</script>
<template>
<SharedWorkReportSearch
v-model:model="model"
report-type="weekly"
:project-options="projectOptions"
@reset="emit('reset')"
@search="emit('search')"
/>
</template>

View File

@@ -91,7 +91,7 @@ onBeforeRouteLeave(async (_to, _from, next) => {
</script>
<template>
<div class="workbench">
<div class="workbench work-report-page-shell">
<WorkbenchBanner />
<WorkbenchEditOverlay

View File

@@ -3,14 +3,23 @@ import { computed, markRaw, onMounted, ref, watch } from 'vue';
import type { RouteKey } from '@elegant-router/types';
import {
fetchApproveOvertimeApplication,
fetchGetMonthlyReportApprovalPage,
fetchGetOvertimeApplicationApprovalPage,
fetchGetProjectReportApprovalPage,
fetchGetWeeklyReportApprovalPage,
fetchRejectOvertimeApplication
} from '@/service/api';
import { useRouterPush } from '@/hooks/common/router';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-dialog.vue';
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
import OvertimeApplicationStatusLogDialog from '@/views/personal-center/overtime-application/modules/overtime-application-status-log-dialog.vue';
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatPeriod
} from '@/views/personal-center/work-report/shared/types';
import {
type WorkbenchTodoDeadlineFilter,
type WorkbenchTodoItem,
@@ -24,14 +33,11 @@ import {
import { workbenchTodoMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiHistory from '~icons/mdi/history';
type SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject';
type ApprovalBizType = 'overtime_application';
type ApprovalBizType = 'overtime_application' | WorkReportType;
defineOptions({ name: 'WorkbenchTodoPanel' });
@@ -53,7 +59,7 @@ const PAGE_SIZE = 5;
const activeTab = ref<WorkbenchTodoMainTab>('all');
const activeDeadlineFilter = ref<WorkbenchTodoDeadlineFilter>(null);
const activeApprovalBizType = ref<ApprovalBizType>('overtime_application');
const activeApprovalBizType = ref<ApprovalBizType>('weekly');
const activeSort = ref<SortKey>('deadline');
const currentPage = ref(1);
@@ -83,31 +89,37 @@ const deadlineFilters: Array<{ key: Exclude<WorkbenchTodoDeadlineFilter, null>;
];
const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' },
{ key: 'project', label: '项目半月报' },
{ key: 'overtime_application', label: '加班申请' }
];
const allItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const projectApprovalRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
const mergedItems = computed(() => {
const mockItems = allItems.value.filter(item => item.category !== 'approval');
return [...mockItems, ...overtimeApprovalItems.value];
return [...mockItems, ...overtimeApprovalItems.value, ...workReportApprovalItems.value];
});
const addDialogVisible = ref(false);
const overtimeDetailVisible = ref(false);
const overtimeStatusLogVisible = ref(false);
const overtimeActionVisible = ref(false);
const overtimeActionSubmitting = ref(false);
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve');
const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly');
const OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline),
approve: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiCloseCircleOutline),
statusLog: markRaw(IconMdiHistory)
detail: markRaw(IconMdiEyeOutline)
};
function handleOpenAdd() {
@@ -161,13 +173,20 @@ const filteredItems = computed(() => {
const approvalBizTabCounts = computed(() => {
const counts: Record<ApprovalBizType, number> = {
overtime_application: 0
overtime_application: 0,
weekly: 0,
monthly: 0,
project: 0
};
itemsInTab.value.forEach(item => {
if (item.approvalBizType === 'overtime_application') {
counts.overtime_application += 1;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
counts[item.approvalBizType] += 1;
}
});
return counts;
@@ -229,10 +248,19 @@ function handleClickItem(item: WorkbenchTodoItem) {
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
openWorkReportDetail(item);
return;
}
if (!item.routeKey) return;
routerPushByKey(item.routeKey as RouteKey);
}
function isWorkReportApprovalBizType(value?: string): value is WorkReportType {
return value === 'weekly' || value === 'monthly' || value === 'project';
}
function findOvertimeApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId) {
return null;
@@ -249,23 +277,38 @@ function openOvertimeDetail(item: WorkbenchTodoItem) {
overtimeDetailVisible.value = true;
}
function openOvertimeStatusLog(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item);
if (!row) return;
function openCurrentOvertimeAction(actionType: OvertimeApprovalActionType) {
if (!currentOvertimeApplication.value) return;
currentOvertimeApplication.value = row;
overtimeStatusLogVisible.value = true;
}
function openOvertimeAction(item: WorkbenchTodoItem, actionType: OvertimeApprovalActionType) {
const row = findOvertimeApprovalRow(item);
if (!row) return;
currentOvertimeApplication.value = row;
currentOvertimeActionType.value = actionType;
overtimeActionVisible.value = true;
}
function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) {
return null;
}
if (item.approvalBizType === 'weekly') {
return weeklyApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
if (item.approvalBizType === 'monthly') {
return monthlyApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
return projectApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function openWorkReportDetail(item: WorkbenchTodoItem) {
const row = findWorkReportApprovalRow(item);
if (!row || !isWorkReportApprovalBizType(item.approvalBizType)) return;
currentWorkReport.value = row;
currentWorkReportType.value = item.approvalBizType;
workReportDetailVisible.value = true;
}
async function handleOvertimeActionSubmit(reason: string | null) {
if (!currentOvertimeApplication.value) {
return;
@@ -288,6 +331,11 @@ async function handleOvertimeActionSubmit(reason: string | null) {
await loadOvertimeApprovalItems();
}
async function handleWorkReportSubmitted() {
workReportDetailVisible.value = false;
await loadWorkReportApprovalItems();
}
async function loadOvertimeApprovalItems() {
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1,
@@ -323,13 +371,80 @@ async function loadOvertimeApprovalItems() {
);
}
function buildWorkReportApprovalItems<T extends WorkReportRow>(
bizType: WorkReportType,
rows: T[]
): WorkbenchTodoItem[] {
const reportTypeLabel = WORK_REPORT_TYPE_LABEL[bizType];
return buildWorkbenchTodoItems(
rows.map(item => ({
id: `${bizType}-${item.id}`,
category: 'approval',
title: `${reportTypeLabel} · ${formatPeriod(item)} 待审批`,
createdTime: item.submitTime || item.createTime || '',
deadline: item.submitTime || item.createTime || null,
source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`,
priority: 'mid',
approvalBizType: bizType,
approvalBizId: item.id
}))
);
}
async function loadWorkReportApprovalItems() {
const [weeklyResult, monthlyResult, projectResult] = await Promise.all([
fetchGetWeeklyReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined,
isBusinessTrip: undefined
}),
fetchGetMonthlyReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined
}),
fetchGetProjectReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined,
projectId: undefined,
flag: undefined
})
]);
weeklyApprovalRows.value = weeklyResult.error || !weeklyResult.data ? [] : weeklyResult.data.list;
monthlyApprovalRows.value = monthlyResult.error || !monthlyResult.data ? [] : monthlyResult.data.list;
projectApprovalRows.value = projectResult.error || !projectResult.data ? [] : projectResult.data.list;
workReportApprovalItems.value = [
...buildWorkReportApprovalItems('weekly', weeklyApprovalRows.value),
...buildWorkReportApprovalItems('monthly', monthlyApprovalRows.value),
...buildWorkReportApprovalItems('project', projectApprovalRows.value)
];
}
function getDeadlineToneClass(item: WorkbenchTodoItem) {
if (isWorkbenchTodoOverdue(item)) return 'workbench-todo__deadline--rose';
if (item.remainingDays === 0) return 'workbench-todo__deadline--amber';
return 'workbench-todo__deadline--slate';
}
onMounted(loadOvertimeApprovalItems);
onMounted(async () => {
await Promise.all([loadOvertimeApprovalItems(), loadWorkReportApprovalItems()]);
});
</script>
<template>
@@ -447,29 +562,15 @@ onMounted(loadOvertimeApprovalItems);
<component :is="OVERTIME_APPROVAL_ACTION_ICONS.detail" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="通过">
<ElButton
link
type="success"
class="workbench-todo__action-btn"
@click="openOvertimeAction(item, 'approve')"
>
<component :is="OVERTIME_APPROVAL_ACTION_ICONS.approve" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="退回">
<ElButton
link
type="danger"
class="workbench-todo__action-btn"
@click="openOvertimeAction(item, 'reject')"
>
<component :is="OVERTIME_APPROVAL_ACTION_ICONS.reject" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="状态日志">
<ElButton link type="info" class="workbench-todo__action-btn" @click="openOvertimeStatusLog(item)">
<component :is="OVERTIME_APPROVAL_ACTION_ICONS.statusLog" class="text-15px" />
</div>
<div
v-else-if="isWorkReportApprovalBizType(item.approvalBizType)"
class="workbench-todo__actions"
@click.stop
>
<ElTooltip content="详情">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openWorkReportDetail(item)">
<component :is="OVERTIME_APPROVAL_ACTION_ICONS.detail" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
@@ -503,10 +604,13 @@ onMounted(loadOvertimeApprovalItems);
@submitted="handleAddSubmitted"
/>
<OvertimeApplicationDetailDialog v-model:visible="overtimeDetailVisible" :row-data="currentOvertimeApplication" />
<OvertimeApplicationStatusLogDialog
v-model:visible="overtimeStatusLogVisible"
<OvertimeApplicationDetailDialog
v-model:visible="overtimeDetailVisible"
:row-data="currentOvertimeApplication"
show-approval-actions
:action-loading="overtimeActionSubmitting"
@approve="openCurrentOvertimeAction('approve')"
@reject="openCurrentOvertimeAction('reject')"
/>
<OvertimeApplicationActionDialog
v-model:visible="overtimeActionVisible"
@@ -514,6 +618,15 @@ onMounted(loadOvertimeApprovalItems);
:loading="overtimeActionSubmitting"
@submit="handleOvertimeActionSubmit"
/>
<WorkReportPrototypePageDialog
v-model:visible="workReportDetailVisible"
mode="detail"
scene="approval"
:report-type="currentWorkReportType"
:row-data="currentWorkReport"
@submitted="handleWorkReportSubmitted"
/>
</WorkbenchModuleCard>
</template>