feat(新增加班申请功能): 新增申请功能,可在工作台进行审核。

fix(dict_data): 在字典数据新增、编辑时可以操作颜色类型字段(color_type)。
This commit is contained in:
dk
2026-06-01 21:37:08 +08:00
parent b2da882b31
commit d3d0830820
29 changed files with 1966 additions and 23 deletions

View File

@@ -151,9 +151,14 @@ export function setupElegantRouter() {
order: 5, order: 5,
keepAlive: true keepAlive: true
}, },
'personal-center_overtime-application': {
icon: 'mdi:clock-plus-outline',
order: 6,
keepAlive: true
},
'personal-center_pending-approval': { 'personal-center_pending-approval': {
icon: 'mdi:check-decagram-outline', icon: 'mdi:check-decagram-outline',
order: 6, order: 7,
keepAlive: true keepAlive: true
}, },
system: { system: {

View File

@@ -1,12 +1,12 @@
{ {
"generatedAt": "2026-05-19T07:08:28.081Z", "generatedAt": "2026-06-01T01:55:51.875Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>", "pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>" "singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
}, },
"total": 22, "total": 23,
"items": [ "items": [
{ {
"name": "product_list", "name": "product_list",
@@ -470,6 +470,39 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "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", "name": "system_user",
"path": "/system/user", "path": "/system/user",

View File

@@ -111,3 +111,19 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty * 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
*/ */
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty'; export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班申请状态字典编码
*
* 对应业务字段:加班申请中的 statusCode
* 来源口径:`overtime-application-design.md` 明确状态字典为 rdms_overtime_application_status
*/
export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_application_status';
/**
* 加班时长快捷选项字典编码
*
* 对应业务字段:加班申请中的 overtimeDuration
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
*/
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';

View File

@@ -16,7 +16,8 @@ export type StatusDomain =
| 'product' | 'product'
| 'requirement' | 'requirement'
| 'workOrder' | 'workOrder'
| 'personalItem'; | 'personalItem'
| 'overtimeApplication';
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = { const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
// 项目-执行 // 项目-执行
@@ -61,6 +62,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
active: 'primary', active: 'primary',
completed: 'success', completed: 'success',
cancelled: 'danger' cancelled: 'danger'
},
// 加班申请
overtimeApplication: {
pending: 'warning',
approved: 'success',
rejected: 'danger',
cancelled: 'info'
} }
}; };
@@ -69,9 +77,13 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
return 'info'; return 'info';
} }
return statusTagTypeRegistry[domain][statusCode] || 'info'; return statusTagTypeRegistry[domain]?.[statusCode] || 'info';
} }
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) { export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('personalItem', statusCode); return getStatusTagType('personalItem', statusCode);
} }
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('overtimeApplication', statusCode);
}

View File

@@ -173,6 +173,7 @@ const local: App.I18n.Schema = {
'personal-center_my-monthly': 'My Monthly Report', 'personal-center_my-monthly': 'My Monthly Report',
'personal-center_my-performance': 'My Performance', 'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application', 'personal-center_my-application': 'My Application',
'personal-center_overtime-application': 'Overtime Application',
'personal-center_pending-approval': 'Pending Approval', 'personal-center_pending-approval': 'Pending Approval',
infra: 'Infra', infra: 'Infra',
'infra_state-machine': 'State Machine', 'infra_state-machine': 'State Machine',
@@ -708,6 +709,7 @@ const local: App.I18n.Schema = {
dictStatus: 'Dictionary Status', dictStatus: 'Dictionary Status',
dictLabel: 'Dictionary Label', dictLabel: 'Dictionary Label',
dictValue: 'Dictionary Value', dictValue: 'Dictionary Value',
colorType: 'Color Type',
sort: 'Sort', sort: 'Sort',
remark: 'Remark', remark: 'Remark',
form: { form: {
@@ -716,6 +718,7 @@ const local: App.I18n.Schema = {
dictStatus: 'Please select dictionary status', dictStatus: 'Please select dictionary status',
dictLabel: 'Please enter dictionary label', dictLabel: 'Please enter dictionary label',
dictValue: 'Please enter dictionary value', dictValue: 'Please enter dictionary value',
colorType: 'Please enter color type',
sort: 'Please enter sort', sort: 'Please enter sort',
remark: 'Please enter remark' remark: 'Please enter remark'
}, },

View File

@@ -173,6 +173,7 @@ const local: App.I18n.Schema = {
'personal-center_my-monthly': '我的月报', 'personal-center_my-monthly': '我的月报',
'personal-center_my-performance': '我的绩效', 'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请', 'personal-center_my-application': '我的申请',
'personal-center_overtime-application': '加班申请',
'personal-center_pending-approval': '待我审批', 'personal-center_pending-approval': '待我审批',
infra: '基础设施', infra: '基础设施',
'infra_state-machine': '状态机管理', 'infra_state-machine': '状态机管理',
@@ -696,6 +697,7 @@ const local: App.I18n.Schema = {
dictStatus: '字典状态', dictStatus: '字典状态',
dictLabel: '字典标签', dictLabel: '字典标签',
dictValue: '字典键值', dictValue: '字典键值',
colorType: '颜色类型',
sort: '排序', sort: '排序',
remark: '备注', remark: '备注',
form: { form: {
@@ -704,6 +706,7 @@ const local: App.I18n.Schema = {
dictStatus: '请选择字典状态', dictStatus: '请选择字典状态',
dictLabel: '请输入字典标签', dictLabel: '请输入字典标签',
dictValue: '请输入字典键值', dictValue: '请输入字典键值',
colorType: '请输入颜色类型',
sort: '请输入排序', sort: '请输入排序',
remark: '请输入备注' remark: '请输入备注'
}, },

View File

@@ -39,6 +39,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"), "personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"), "personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"), "personal-center_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_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"), plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"), plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),

View File

@@ -351,6 +351,18 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'personal-center_overtime-application',
path: '/personal-center/overtime-application',
component: 'view.personal-center_overtime-application',
meta: {
title: 'personal-center_overtime-application',
i18nKey: 'route.personal-center_overtime-application',
icon: 'mdi:clock-plus-outline',
order: 6,
keepAlive: true
}
},
{ {
name: 'personal-center_pending-approval', name: 'personal-center_pending-approval',
path: '/personal-center/pending-approval', path: '/personal-center/pending-approval',
@@ -359,7 +371,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'personal-center_pending-approval', title: 'personal-center_pending-approval',
i18nKey: 'route.personal-center_pending-approval', i18nKey: 'route.personal-center_pending-approval',
icon: 'mdi:check-decagram-outline', icon: 'mdi:check-decagram-outline',
order: 5, order: 7,
keepAlive: true keepAlive: true
} }
} }

View File

@@ -196,6 +196,7 @@ const routeMap: RouteMap = {
"personal-center_my-performance": "/personal-center/my-performance", "personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile", "personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly", "personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_overtime-application": "/personal-center/overtime-application",
"personal-center_pending-approval": "/personal-center/pending-approval", "personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin", "plugin": "/plugin",
"plugin_barcode": "/plugin/barcode", "plugin_barcode": "/plugin/barcode",

View File

@@ -1,5 +1,6 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service'; import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request'; import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult } from './shared';
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`; const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`; const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
@@ -15,6 +16,52 @@ function createBatchDeleteQuery(ids: number[]) {
return query.toString(); return query.toString();
} }
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
};
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
list: DictDataResponse[];
};
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
colorType?: string | null;
color_type?: string | null;
};
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
function normalizeColorType(value?: string | null) {
return value?.trim() || null;
}
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
};
}
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
return {
...rest,
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
};
}
function toSaveDictDataRequest(data: Api.Dict.SaveDictDataParams) {
return {
...data,
colorType: normalizeColorType(data.colorType),
remark: data.remark?.trim() || null
};
}
/** 获取字典类型分页 */ /** 获取字典类型分页 */
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) { export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
return request<Api.Dict.PageResult<Api.Dict.DictType>>({ return request<Api.Dict.PageResult<Api.Dict.DictType>>({
@@ -60,20 +107,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
} }
/** 获取字典数据分页 */ /** 获取字典数据分页 */
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) { export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
return request<Api.Dict.PageResult<Api.Dict.DictData>>({ const result = await request<DictDataPageResponse>({
url: `${DICT_DATA_PREFIX}/page`, url: `${DICT_DATA_PREFIX}/page`,
method: 'get', method: 'get',
params params
}); });
if (result.error || !result.data) {
return result as unknown as Awaited<ReturnType<typeof request<Api.Dict.PageResult<Api.Dict.DictData>>>>;
}
return {
...result,
data: {
...result.data,
list: result.data.list.map(normalizeDictData)
}
};
} }
/** 获取前端运行时字典缓存 */ /** 获取前端运行时字典缓存 */
export function fetchGetFrontendDictCache() { export async function fetchGetFrontendDictCache() {
return request<Api.Dict.FrontendDictCache>({ const result = await request<FrontendDictCacheResponse>({
url: `${DICT_DATA_PREFIX}/frontend-cache`, url: `${DICT_DATA_PREFIX}/frontend-cache`,
method: 'get' method: 'get'
}); });
return mapServiceResult(
result as ServiceRequestResult<FrontendDictCacheResponse>,
data =>
Object.fromEntries(
Object.entries(data).map(([dictType, list]) => [dictType, list.map(normalizeFrontendDictData)])
) as Api.Dict.FrontendDictCache
);
} }
/** 创建字典数据 */ /** 创建字典数据 */
@@ -81,7 +148,7 @@ export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
return request<number>({ return request<number>({
url: `${DICT_DATA_PREFIX}/create`, url: `${DICT_DATA_PREFIX}/create`,
method: 'post', method: 'post',
data data: toSaveDictDataRequest(data)
}); });
} }
@@ -90,7 +157,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
return request<boolean>({ return request<boolean>({
url: `${DICT_DATA_PREFIX}/update`, url: `${DICT_DATA_PREFIX}/update`,
method: 'put', method: 'put',
data data: toSaveDictDataRequest(data)
}); });
} }
@@ -112,9 +179,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
} }
/** 通过岗位编码获取该字典的所有字典数据 */ /** 通过岗位编码获取该字典的所有字典数据 */
export function fetchGetDictDataByCode(code: string) { export async function fetchGetDictDataByCode(code: string) {
return request<Api.Dict.PageResult<Api.Dict.DictData>>({ const result = await request<DictDataPageResponse>({
url: `${DICT_DATA_PREFIX}/code?code=${code}`, url: `${DICT_DATA_PREFIX}/code?code=${code}`,
method: 'get' method: 'get'
}); });
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
...data,
list: data.list.map(normalizeDictData)
}));
} }

View File

@@ -3,6 +3,7 @@ export * from './dict';
export * from './file'; export * from './file';
export * from './infra'; export * from './infra';
export * from './object-context'; export * from './object-context';
export * from './overtime-application';
export * from './personal-item'; export * from './personal-item';
export * from './product'; export * from './product';
export * from './project'; export * from './project';

View File

@@ -0,0 +1,280 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
type StringIdResponse = string | number;
type OvertimeApplicationResponse = Omit<
Api.OvertimeApplication.OvertimeApplication,
'id' | 'applicantId' | 'approverId' | 'overtimeDate' | 'allowEdit' | 'terminal'
> & {
id: StringIdResponse;
applicantId: StringIdResponse;
approverId: StringIdResponse;
overtimeDate: ProjectLocalDateValue;
allowEdit?: boolean | number | string | null;
terminal?: boolean | number | string | null;
};
type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeApplicationPageResult, 'total' | 'list'> & {
total: number | string;
list: OvertimeApplicationResponse[];
};
type OvertimeApplicationStatusLogResponse = Omit<
Api.OvertimeApplication.OvertimeApplicationStatusLog,
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot'
> & {
id: StringIdResponse;
applicationId: StringIdResponse;
operatorUserId: StringIdResponse;
overtimeDateSnapshot: ProjectLocalDateValue;
};
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return !['', '0', 'false', 'n', 'no'].includes(normalized);
}
return false;
}
function normalizeTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeOvertimeApplication(
response: OvertimeApplicationResponse
): Api.OvertimeApplication.OvertimeApplication {
return {
...response,
id: normalizeStringId(response.id),
applicantId: normalizeStringId(response.applicantId),
approverId: normalizeStringId(response.approverId),
overtimeDate: normalizeProjectLocalDate(response.overtimeDate) ?? '',
statusName: response.statusName || response.statusCode,
allowEdit: normalizeBooleanFlag(response.allowEdit),
terminal: normalizeBooleanFlag(response.terminal),
approvalComment: response.approvalComment ?? null,
approvalTime: response.approvalTime ?? null
};
}
function normalizeStatusLog(
response: OvertimeApplicationStatusLogResponse
): Api.OvertimeApplication.OvertimeApplicationStatusLog {
return {
...response,
id: normalizeStringId(response.id),
applicationId: normalizeStringId(response.applicationId),
operatorUserId: normalizeStringId(response.operatorUserId),
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '',
fromStatus: normalizeNullableStringId(response.fromStatus),
reason: response.reason ?? null,
remark: response.remark ?? null
};
}
function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo ?? 1));
query.append('pageSize', String(params.pageSize ?? 10));
if (params.keyword) {
query.append('keyword', params.keyword);
}
if (params.applicantName) {
query.append('applicantName', params.applicantName);
}
if (params.approverId) {
query.append('approverId', params.approverId);
}
if (params.approverName) {
query.append('approverName', params.approverName);
}
if (params.statusCode) {
query.append('statusCode', params.statusCode);
}
params.overtimeDate?.forEach(item => {
if (item) {
query.append('overtimeDate', item);
}
});
params.createTime?.forEach(item => {
if (item) {
query.append('createTime', item);
}
});
return query.toString();
}
function toSaveRequest(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
return {
overtimeDate: data.overtimeDate,
overtimeDuration: data.overtimeDuration,
overtimeReason: data.overtimeReason.trim(),
overtimeContent: data.overtimeContent.trim(),
approverId: data.approverId
};
}
function toStatusActionRequest(data: Api.OvertimeApplication.StatusActionParams = {}) {
return {
reason: data.reason?.trim() || undefined
};
}
export async function fetchGetOvertimeApplicationPage(
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
) {
const query = createPageQuery(params);
const result = await request<OvertimeApplicationPageResponse>({
...safeJsonRequestConfig,
url: query ? `${OVERTIME_APPLICATION_PREFIX}/page?${query}` : `${OVERTIME_APPLICATION_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeOvertimeApplication)
}));
}
export async function fetchGetOvertimeApplicationApprovalPage(
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
) {
const query = createPageQuery(params);
const result = await request<OvertimeApplicationPageResponse>({
...safeJsonRequestConfig,
url: query
? `${OVERTIME_APPLICATION_PREFIX}/approval-page?${query}`
: `${OVERTIME_APPLICATION_PREFIX}/approval-page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
total: normalizeTotal(data.total),
list: data.list.map(normalizeOvertimeApplication)
}));
}
export async function fetchGetOvertimeApplicationDetail(id: string) {
const result = await request<OvertimeApplicationResponse>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationResponse>, normalizeOvertimeApplication);
}
export async function fetchCreateOvertimeApplication(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: OVERTIME_APPLICATION_PREFIX,
method: 'post',
data: toSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdateRejectedOvertimeApplication(
id: string,
data: Api.OvertimeApplication.SaveOvertimeApplicationParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'put',
data: toSaveRequest(data)
});
}
export function fetchApproveOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams = {}) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approve`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/reject`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchCancelOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`,
method: 'post',
data: toStatusActionRequest(data)
});
}
export function fetchDeleteOvertimeApplication(id: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
method: 'delete'
});
}
export async function fetchGetOvertimeApplicationStatusLogs(id: string) {
const result = await request<OvertimeApplicationStatusLogResponse[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data =>
data.map(normalizeStatusLog)
);
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params);
return request<Blob, 'blob'>({
url: query ? `${OVERTIME_APPLICATION_PREFIX}/export?${query}` : `${OVERTIME_APPLICATION_PREFIX}/export`,
method: 'get',
responseType: 'blob'
});
}

View File

@@ -455,6 +455,19 @@ export async function fetchGetUserSimpleList() {
); );
} }
/** 获取当前登录人的直属上级 */
export async function fetchGetLoginUserDirectManager() {
return request<UserSimpleResponse | null>({
...safeJsonRequestConfig,
url: `${USER_PREFIX}/profile/direct-manager`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse | null>, data =>
data ? normalizeUserSimple(data) : null
)
);
}
/** 获取用户分页 */ /** 获取用户分页 */
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) { export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({ return request<Api.SystemManage.UserList>({

View File

@@ -88,7 +88,7 @@ declare namespace Api {
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams; type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
/** dict data save params */ /** dict data save params */
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status'> & { type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status' | 'colorType'> & {
remark?: string | null; remark?: string | null;
}; };
} }

View File

@@ -0,0 +1,78 @@
declare namespace Api {
namespace OvertimeApplication {
interface PageParams {
pageNo: number;
pageSize: number;
}
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected' | 'cancelled';
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject' | 'cancel';
interface OvertimeApplication {
id: string;
applicantId: string;
applicantName: string;
overtimeDate: string;
overtimeDuration: string;
overtimeReason: string;
overtimeContent: string;
approverId: string;
approverName: string;
statusCode: OvertimeApplicationStatusCode;
statusName: string;
allowEdit: boolean;
terminal: boolean;
approvalComment?: string | null;
submitTime: string;
approvalTime?: string | null;
createTime: string;
updateTime: string;
}
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
applicantName: string;
approverId: string;
approverName: string;
statusCode: OvertimeApplicationStatusCode;
overtimeDate: string[];
createTime: string[];
}
>;
interface OvertimeApplicationPageResult {
total: number;
list: OvertimeApplication[];
}
interface SaveOvertimeApplicationParams {
overtimeDate: string;
overtimeDuration: string;
overtimeReason: string;
overtimeContent: string;
approverId: string;
}
interface StatusActionParams {
reason?: string | null;
}
interface OvertimeApplicationStatusLog {
id: string;
applicationId: string;
actionType: OvertimeApplicationActionType;
fromStatus?: string | null;
toStatus: string;
reason?: string | null;
operatorUserId: string;
operatorName: string;
applicantNameSnapshot: string;
overtimeDateSnapshot: string;
overtimeDurationSnapshot: string;
remark?: string | null;
createTime: string;
}
}
}

View File

@@ -866,6 +866,7 @@ declare namespace App {
dictStatus: string; dictStatus: string;
dictLabel: string; dictLabel: string;
dictValue: string; dictValue: string;
colorType: string;
sort: string; sort: string;
remark: string; remark: string;
form: { form: {
@@ -874,6 +875,7 @@ declare namespace App {
dictStatus: string; dictStatus: string;
dictLabel: string; dictLabel: string;
dictValue: string; dictValue: string;
colorType: string;
sort: string; sort: string;
remark: string; remark: string;
}; };

View File

@@ -150,6 +150,7 @@ declare module 'vue' {
IconMdiCrown: typeof import('~icons/mdi/crown')['default'] IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default'] IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default'] IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default'] IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default'] IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']

View File

@@ -50,6 +50,7 @@ declare module "@elegant-router/types" {
"personal-center_my-performance": "/personal-center/my-performance"; "personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile"; "personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly"; "personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_overtime-application": "/personal-center/overtime-application";
"personal-center_pending-approval": "/personal-center/pending-approval"; "personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin"; "plugin": "/plugin";
"plugin_barcode": "/plugin/barcode"; "plugin_barcode": "/plugin/barcode";
@@ -187,6 +188,7 @@ declare module "@elegant-router/types" {
| "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_pending-approval" | "personal-center_pending-approval"
| "plugin_barcode" | "plugin_barcode"
| "plugin_charts_antv" | "plugin_charts_antv"

View File

@@ -0,0 +1,401 @@
<script setup lang="tsx">
import { computed, markRaw, reactive, ref } from 'vue';
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
fetchCancelOvertimeApplication,
fetchDeleteOvertimeApplication,
fetchExportOvertimeApplications,
fetchGetOvertimeApplicationPage
} from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import OvertimeApplicationActionDialog from './modules/overtime-application-action-dialog.vue';
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
import OvertimeApplicationSearch from './modules/overtime-application-search.vue';
import OvertimeApplicationStatusLogDialog from './modules/overtime-application-status-log-dialog.vue';
import {
downloadBlob,
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './modules/overtime-application-shared';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiHistory from '~icons/mdi/history';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'OvertimeApplication' });
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
type ActionType = 'cancel';
function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
applicantName: undefined,
approverId: undefined,
approverName: undefined,
statusCode: undefined,
overtimeDate: undefined,
createTime: undefined
};
}
function transformPageResult(response: OvertimeApplicationPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: 1,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const operateVisible = ref(false);
const detailVisible = ref(false);
const statusLogVisible = ref(false);
const actionVisible = ref(false);
const operateType = ref<'add' | 'edit'>('add');
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const currentActionType = ref<ActionType>('cancel');
const actionSubmitting = ref(false);
const exporting = ref(false);
const ACTION_ICON_MAP = {
detail: markRaw(IconMdiEyeOutline),
statusLog: markRaw(IconMdiHistory),
edit: markRaw(IconMdiPencilOutline),
cancel: markRaw(IconMdiCloseCircleOutline),
delete: markRaw(IconMdiDeleteOutline)
};
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
OvertimeApplicationPageResponse,
Api.OvertimeApplication.OvertimeApplication
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetOvertimeApplicationPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
{
prop: 'overtimeDate',
label: '加班日期',
width: 120,
formatter: row => formatOvertimeDate(row.overtimeDate)
},
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
{
prop: 'overtimeReason',
label: '加班原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => formatEmptyText(row.overtimeReason)
},
{
prop: 'overtimeContent',
label: '加班内容',
minWidth: 200,
showOverflowTooltip: true,
formatter: row => formatEmptyText(row.overtimeContent)
},
{
prop: 'statusCode',
label: '状态',
width: 110,
align: 'center',
formatter: row => (
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
</ElTag>
)
},
{ prop: 'approverName', label: '审核人', minWidth: 120, showOverflowTooltip: true },
{
prop: 'submitTime',
label: '提交时间',
minWidth: 170,
formatter: row => formatOvertimeDateTime(row.submitTime)
},
{
prop: 'approvalTime',
label: '审核时间',
minWidth: 170,
formatter: row => formatOvertimeDateTime(row.approvalTime)
},
{
prop: 'operate',
label: '操作',
width: 170,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
}
]
});
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
buttonType: 'primary',
icon: ACTION_ICON_MAP.detail,
onClick: () => openDetail(row)
}
];
if (row.statusCode === 'rejected' && row.allowEdit) {
actions.push({
key: 'edit',
label: '修改',
buttonType: 'primary',
icon: ACTION_ICON_MAP.edit,
onClick: () => openEdit(row)
});
}
actions.push({
key: 'status-log',
label: '状态日志',
buttonType: 'info',
icon: ACTION_ICON_MAP.statusLog,
onClick: () => openStatusLog(row)
});
if (row.statusCode === 'pending' || row.statusCode === 'rejected') {
actions.push({
key: 'cancel',
label: '撤销',
buttonType: 'danger',
icon: ACTION_ICON_MAP.cancel,
onClick: () => openCancel(row)
});
}
if (row.statusCode === 'cancelled') {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
onClick: () => handleDelete(row)
});
}
return actions;
}
function openAdd() {
operateType.value = 'add';
currentRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.OvertimeApplication.OvertimeApplication) {
operateType.value = 'edit';
currentRow.value = row;
operateVisible.value = true;
}
function openDetail(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
detailVisible.value = true;
}
function openStatusLog(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
statusLogVisible.value = true;
}
function openCancel(row: Api.OvertimeApplication.OvertimeApplication) {
currentRow.value = row;
currentActionType.value = 'cancel';
actionVisible.value = true;
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
function resetSearchParams() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), { pageSize });
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
operateVisible.value = false;
reloadTable(searchParams.pageNo ?? 1);
}
async function handleActionSubmit(reason: string | null) {
if (!currentRow.value) {
return;
}
actionSubmitting.value = true;
const { error } = await fetchCancelOvertimeApplication(currentRow.value.id, { reason });
actionSubmitting.value = false;
if (error) {
return;
}
actionVisible.value = false;
window.$message?.success('加班申请已撤销');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleDelete(row: Api.OvertimeApplication.OvertimeApplication) {
try {
await ElMessageBox.confirm(
`确定删除 ${row.applicantName} ${formatOvertimeDate(row.overtimeDate)} 的加班申请吗?`,
'删除确认',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
}
);
} catch {
return;
}
const { error } = await fetchDeleteOvertimeApplication(row.id);
if (error) {
return;
}
window.$message?.success('加班申请已删除');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
}
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<OvertimeApplicationSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p class="text-16px font-600">加班申请</p>
<ElTag effect="plain">{{ totalCount }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<OvertimeApplicationOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="currentRow"
@submitted="handleSubmitted"
/>
<OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" />
<OvertimeApplicationStatusLogDialog v-model:visible="statusLogVisible" :row-data="currentRow" />
<OvertimeApplicationActionDialog
v-model:visible="actionVisible"
:action-type="currentActionType"
:loading="actionSubmitting"
@submit="handleActionSubmit"
/>
</div>
</template>
<style scoped>
:deep(.overtime-application__reason-link) {
max-width: 100%;
padding: 0;
vertical-align: baseline;
}
:deep(.overtime-application__reason-link > span) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'OvertimeApplicationActionDialog' });
type ActionType = 'approve' | 'reject' | 'cancel';
interface Props {
actionType: ActionType;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
const emit = defineEmits<{
submit: [reason: string | null];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive({
reason: ''
});
const title = computed(() => {
const map: Record<ActionType, string> = {
approve: '通过加班申请',
reject: '退回加班申请',
cancel: '撤销加班申请'
};
return map[props.actionType];
});
const reasonLabel = computed(() => {
const map: Record<ActionType, string> = {
approve: '审核意见',
reject: '退回原因',
cancel: '撤销原因'
};
return map[props.actionType];
});
const reasonRequired = computed(() => props.actionType !== 'approve');
const rules = computed(() => ({
reason: reasonRequired.value
? [
createRequiredRule(`请输入${reasonLabel.value}`),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error(`请输入${reasonLabel.value}`));
return;
}
callback();
},
trigger: 'blur'
}
]
: []
}));
async function handleSubmit() {
await validate();
emit('submit', model.reason.trim() || null);
}
watch(
() => visible.value,
async value => {
if (value) {
model.reason = '';
await nextTick();
formRef.value?.clearValidate();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="sm"
:confirm-loading="props.loading"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem :label="reasonLabel" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
:placeholder="reasonRequired ? `请输入${reasonLabel}` : '可填写审核意见'"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const statusTagType = computed(() => resolveOvertimeApplicationStatusTagType(detailData.value?.statusCode));
const statusLabel = computed(() =>
getOvertimeApplicationStatusLabel(detailData.value?.statusCode, detailData.value?.statusName)
);
async function loadDetail() {
if (!props.rowData?.id) {
detailData.value = null;
return;
}
loading.value = true;
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
loading.value = false;
detailData.value = error || !data ? props.rowData : data;
}
watch(
() => visible.value,
value => {
if (value) {
loadDetail();
}
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading" :show-footer="false">
<ElDescriptions v-if="detailData" :column="2" border>
<ElDescriptionsItem label="状态">
<ElTag :type="statusTagType">{{ statusLabel }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="申请人">
{{ detailData.applicantName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期">{{ formatOvertimeDate(detailData.overtimeDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长">{{ detailData.overtimeDuration }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核人">
{{ detailData.approverName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ formatOvertimeDateTime(detailData.submitTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核时间">{{ formatOvertimeDateTime(detailData.approvalTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核意见">{{ formatEmptyText(detailData.approvalComment) }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2">{{ detailData.overtimeReason }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2">{{ detailData.overtimeContent }}</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
</BusinessFormDialog>
</template>
<style scoped>
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper:hover),
:deep(.overtime-application-detail-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.overtime-application-detail-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
import {
fetchCreateOvertimeApplication,
fetchGetLoginUserDirectManager,
fetchGetOvertimeApplicationDetail,
fetchUpdateRejectedOvertimeApplication
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'OvertimeApplicationOperateDialog' });
type OperateType = 'add' | 'edit';
interface Props {
operateType: OperateType;
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const detailLoading = ref(false);
const submitting = ref(false);
const approverName = ref('');
const currentUserName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '--');
const isEdit = computed(() => props.operateType === 'edit');
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增加班申请'));
const model = reactive<Api.OvertimeApplication.SaveOvertimeApplicationParams>(createDefaultModel());
const rules = computed(
() =>
({
overtimeDate: [createRequiredRule('请选择加班日期')],
overtimeDuration: [createRequiredRule('请选择加班时长')],
overtimeReason: [
createRequiredRule('请输入加班原因'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班原因'));
return;
}
callback();
},
trigger: 'blur'
}
],
overtimeContent: [
createRequiredRule('请输入加班内容'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班内容'));
return;
}
callback();
},
trigger: 'blur'
}
],
approverId: [createRequiredRule('请选择审核人')]
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
);
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
return {
overtimeDate: '',
overtimeDuration: '',
overtimeReason: '',
overtimeContent: '',
approverId: ''
};
}
async function loadDirectManagerAsDefaultApprover() {
const { error, data } = await fetchGetLoginUserDirectManager();
if (!error && data?.id) {
model.approverId = data.id;
approverName.value = data.nickname;
}
}
async function initModel() {
detailLoading.value = true;
Object.assign(model, createDefaultModel());
approverName.value = '';
if (isEdit.value && props.rowData) {
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
const detail = error || !data ? props.rowData : data;
model.overtimeDate = detail.overtimeDate;
model.overtimeDuration = detail.overtimeDuration;
model.overtimeReason = detail.overtimeReason;
model.overtimeContent = detail.overtimeContent;
model.approverId = detail.approverId;
approverName.value = detail.approverName;
} else {
await loadDirectManagerAsDefaultApprover();
}
detailLoading.value = false;
await nextTick();
formRef.value?.clearValidate();
}
async function handleSubmit() {
await validate();
const payload: Api.OvertimeApplication.SaveOvertimeApplicationParams = {
overtimeDate: model.overtimeDate,
overtimeDuration: model.overtimeDuration,
overtimeReason: model.overtimeReason.trim(),
overtimeContent: model.overtimeContent.trim(),
approverId: model.approverId
};
submitting.value = true;
const result =
isEdit.value && props.rowData
? await fetchUpdateRejectedOvertimeApplication(props.rowData.id, payload)
: await fetchCreateOvertimeApplication(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success(isEdit.value ? '加班申请已重新提交' : '加班申请已提交');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
value => {
if (value) {
initModel();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="md"
:loading="detailLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<BusinessFormSection title="申请信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="申请人">
<ElInput
class="overtime-application-operate-dialog__readonly-input"
:model-value="currentUserName"
readonly
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="审核人" prop="approverId">
<ElInput
class="overtime-application-operate-dialog__readonly-input"
:model-value="approverName"
readonly
placeholder="暂无直属上级"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班日期" prop="overtimeDate" style="width: 100%">
<ElDatePicker
v-model="model.overtimeDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择加班日期"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班时长" prop="overtimeDuration">
<DictSelect
v-model="model.overtimeDuration"
:dict-code="RDMS_OVERTIME_DURATION_DICT_CODE"
placeholder="请选择加班时长"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="加班说明">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem label="加班原因" prop="overtimeReason">
<ElInput
v-model="model.overtimeReason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入加班原因"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="加班内容" prop="overtimeContent">
<ElInput
v-model="model.overtimeContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入加班内容"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.overtime-application-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.overtime-application-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'OvertimeApplicationSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.OvertimeApplication.OvertimeApplicationSearchParams>('model', {
required: true
});
const searchModel = reactive<Record<string, any>>({
applicantName: '',
overtimeDate: undefined,
statusCode: undefined,
approverName: ''
});
let syncingFromSource = false;
watch(
() =>
[model.value.applicantName, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
([applicantName, overtimeDate, statusCode, approverName]) => {
syncingFromSource = true;
searchModel.applicantName = applicantName ?? '';
searchModel.overtimeDate = overtimeDate;
searchModel.statusCode = statusCode;
searchModel.approverName = approverName ?? '';
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() =>
[searchModel.applicantName, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
([applicantName, overtimeDate, statusCode, approverName]) => {
if (syncingFromSource) {
return;
}
model.value.applicantName = applicantName?.trim() || undefined;
model.value.overtimeDate = overtimeDate;
model.value.statusCode = statusCode;
model.value.approverName = approverName?.trim() || undefined;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'applicantName',
label: '申请人',
type: 'input',
placeholder: '请输入申请人'
},
{
key: 'overtimeDate',
label: '加班日期',
type: 'dateRange',
placeholder: '请选择加班日期'
},
{
key: 'statusCode',
label: '状态',
type: 'dict',
dictCode: RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE,
placeholder: '请选择状态'
},
{
key: 'approverName',
label: '审核人',
type: 'input',
placeholder: '请输入审核人'
}
]);
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="handleReset" @search="handleSearch" />
</template>

View File

@@ -0,0 +1,77 @@
import dayjs from 'dayjs';
import { getStatusTagType } from '@/constants/status-tag';
export const overtimeApplicationStatusOptions: Array<{
label: string;
value: Api.OvertimeApplication.OvertimeApplicationStatusCode;
}> = [
{ label: '待审批', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已退回', value: 'rejected' },
{ label: '已撤销', value: 'cancelled' }
];
export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = {
submit: '提交',
resubmit: '重新提交',
approve: '通过',
reject: '退回',
cancel: '撤销'
};
export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) {
if (statusName) {
return statusName;
}
return overtimeApplicationStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
}
export function resolveOvertimeApplicationStatusTagType(statusCode?: string | null) {
return getStatusTagType('overtimeApplication', statusCode);
}
export function getOvertimeApplicationActionLabel(actionType?: string | null) {
if (!actionType) {
return '--';
}
return (
overtimeApplicationActionNameMap[actionType as Api.OvertimeApplication.OvertimeApplicationActionType] || actionType
);
}
export function formatOvertimeDate(value?: string | null) {
if (!value) {
return '--';
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD') : value;
}
export function formatOvertimeDateTime(value?: string | null) {
if (!value) {
return '--';
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
}
export function formatEmptyText(value?: string | null) {
return value?.trim() || '--';
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
}

View File

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

View File

@@ -93,6 +93,10 @@ function getDictStatusTagType(status: Api.Dict.DictStatus): UI.ThemeColor {
return status === 0 ? 'success' : 'info'; return status === 0 ? 'success' : 'info';
} }
function formatColorType(value?: string | null) {
return value?.trim() || '--';
}
const dictTypeSearchParams = reactive(getInitDictTypeSearchParams()); const dictTypeSearchParams = reactive(getInitDictTypeSearchParams());
const dictDataSearchParams = reactive(getInitDictDataSearchParams()); const dictDataSearchParams = reactive(getInitDictDataSearchParams());
@@ -177,6 +181,13 @@ const {
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 }, { prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
{ prop: 'label', label: $t('page.system.dict.dictLabel'), minWidth: 160 }, { prop: 'label', label: $t('page.system.dict.dictLabel'), minWidth: 160 },
{ prop: 'value', label: $t('page.system.dict.dictValue'), minWidth: 180 }, { prop: 'value', label: $t('page.system.dict.dictValue'), minWidth: 180 },
{
prop: 'colorType',
label: $t('page.system.dict.colorType'),
width: 120,
align: 'center',
formatter: row => formatColorType(row.colorType)
},
{ prop: 'sort', label: $t('page.system.dict.sort'), width: 90, align: 'center' }, { prop: 'sort', label: $t('page.system.dict.sort'), width: 90, align: 'center' },
{ {
prop: 'status', prop: 'status',

View File

@@ -53,6 +53,7 @@ function createDefaultModel(): Model {
label: '', label: '',
value: '', value: '',
dictType: '', dictType: '',
colorType: '',
sort: 0, sort: 0,
status: 0, status: 0,
remark: '' remark: ''
@@ -81,6 +82,7 @@ function handleInitModel() {
label: props.rowData.label, label: props.rowData.label,
value: props.rowData.value, value: props.rowData.value,
dictType: props.rowData.dictType, dictType: props.rowData.dictType,
colorType: props.rowData.colorType ?? '',
sort: props.rowData.sort, sort: props.rowData.sort,
status: props.rowData.status, status: props.rowData.status,
remark: props.rowData.remark ?? '' remark: props.rowData.remark ?? ''
@@ -168,6 +170,11 @@ watch(visible, value => {
<ElInput v-model="model.value" :placeholder="$t('page.system.dict.form.dictValue')" /> <ElInput v-model="model.value" :placeholder="$t('page.system.dict.form.dictValue')" />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12">
<ElFormItem :label="$t('page.system.dict.colorType')" prop="colorType">
<ElInput v-model="model.colorType" clearable :placeholder="$t('page.system.dict.form.colorType')" />
</ElFormItem>
</ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem :label="$t('page.system.dict.sort')" prop="sort"> <ElFormItem :label="$t('page.system.dict.sort')" prop="sort">
<ElInputNumber <ElInputNumber

View File

@@ -62,6 +62,10 @@ export interface WorkbenchTodoItemSource {
overdue?: boolean; overdue?: boolean;
/** 跳转路由 key可选未配则不跳转 */ /** 跳转路由 key可选未配则不跳转 */
routeKey?: string; routeKey?: string;
/** 审批业务类型 */
approvalBizType?: 'overtime_application' | string;
/** 审批业务 ID */
approvalBizId?: string;
} }
export interface WorkbenchTodoItem extends Omit<WorkbenchTodoItemSource, 'deadline'> { export interface WorkbenchTodoItem extends Omit<WorkbenchTodoItemSource, 'deadline'> {

View File

@@ -1,8 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, markRaw, onMounted, ref, watch } from 'vue';
import type { RouteKey } from '@elegant-router/types'; import type { RouteKey } from '@elegant-router/types';
import {
fetchApproveOvertimeApplication,
fetchGetOvertimeApplicationApprovalPage,
fetchRejectOvertimeApplication
} from '@/service/api';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue'; 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 { import {
type WorkbenchTodoDeadlineFilter, type WorkbenchTodoDeadlineFilter,
type WorkbenchTodoItem, type WorkbenchTodoItem,
@@ -15,8 +23,14 @@ import {
} from '../homepage'; } from '../homepage';
import { workbenchTodoMock } from '../mock'; import { workbenchTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue'; 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 SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject';
type ApprovalBizType = 'overtime_application';
defineOptions({ name: 'WorkbenchTodoPanel' }); defineOptions({ name: 'WorkbenchTodoPanel' });
@@ -38,6 +52,7 @@ const PAGE_SIZE = 5;
const activeTab = ref<WorkbenchTodoMainTab>('all'); const activeTab = ref<WorkbenchTodoMainTab>('all');
const activeDeadlineFilter = ref<WorkbenchTodoDeadlineFilter>(null); const activeDeadlineFilter = ref<WorkbenchTodoDeadlineFilter>(null);
const activeApprovalBizType = ref<ApprovalBizType>('overtime_application');
const activeSort = ref<SortKey>('deadline'); const activeSort = ref<SortKey>('deadline');
const currentPage = ref(1); const currentPage = ref(1);
@@ -66,9 +81,33 @@ const deadlineFilters: Array<{ key: Exclude<WorkbenchTodoDeadlineFilter, null>;
{ key: 'week', label: '本周到期' } { key: 'week', label: '本周到期' }
]; ];
const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'overtime_application', label: '加班申请' }
];
const allItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock)); const allItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const mergedItems = computed(() => {
const mockItems = allItems.value.filter(item => item.category !== 'approval');
return [...mockItems, ...overtimeApprovalItems.value];
});
const addDialogVisible = ref(false); 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 OVERTIME_APPROVAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline),
approve: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiCloseCircleOutline),
statusLog: markRaw(IconMdiHistory)
};
function handleOpenAdd() { function handleOpenAdd() {
addDialogVisible.value = true; addDialogVisible.value = true;
@@ -81,13 +120,13 @@ function handleAddSubmitted() {
const tabCounts = computed(() => { const tabCounts = computed(() => {
const counts: Record<WorkbenchTodoMainTab, number> = { const counts: Record<WorkbenchTodoMainTab, number> = {
all: allItems.value.length, all: mergedItems.value.length,
task: 0, task: 0,
ticket: 0, ticket: 0,
personal: 0, personal: 0,
approval: 0 approval: 0
}; };
allItems.value.forEach(item => { mergedItems.value.forEach(item => {
counts[item.category] += 1; counts[item.category] += 1;
}); });
return counts; return counts;
@@ -101,7 +140,7 @@ const tabOverdueCount = computed(() => {
personal: 0, personal: 0,
approval: 0 approval: 0
}; };
allItems.value.forEach(item => { mergedItems.value.forEach(item => {
if (!isWorkbenchTodoOverdue(item)) return; if (!isWorkbenchTodoOverdue(item)) return;
map.all += 1; map.all += 1;
map[item.category] += 1; map[item.category] += 1;
@@ -109,9 +148,29 @@ const tabOverdueCount = computed(() => {
return map; return map;
}); });
const itemsInTab = computed(() => filterWorkbenchTodoItemsByCategory(allItems.value, activeTab.value)); const itemsInTab = computed(() => filterWorkbenchTodoItemsByCategory(mergedItems.value, activeTab.value));
const filteredItems = computed(() => filterWorkbenchTodoItemsByDeadline(itemsInTab.value, activeDeadlineFilter.value)); const filteredItems = computed(() => {
if (activeTab.value === 'approval') {
return itemsInTab.value.filter(item => item.approvalBizType === activeApprovalBizType.value);
}
return filterWorkbenchTodoItemsByDeadline(itemsInTab.value, activeDeadlineFilter.value);
});
const approvalBizTabCounts = computed(() => {
const counts: Record<ApprovalBizType, number> = {
overtime_application: 0
};
itemsInTab.value.forEach(item => {
if (item.approvalBizType === 'overtime_application') {
counts.overtime_application += 1;
}
});
return counts;
});
const sortedItems = computed(() => { const sortedItems = computed(() => {
const base = filteredItems.value; const base = filteredItems.value;
@@ -155,20 +214,121 @@ function handleSelectDeadlineFilter(key: Exclude<WorkbenchTodoDeadlineFilter, nu
activeDeadlineFilter.value = activeDeadlineFilter.value === key ? null : key; activeDeadlineFilter.value = activeDeadlineFilter.value === key ? null : key;
} }
function handleSelectApprovalBizType(key: ApprovalBizType) {
activeApprovalBizType.value = key;
}
function handleSelectSort(key: SortKey) { function handleSelectSort(key: SortKey) {
activeSort.value = key; activeSort.value = key;
} }
function handleClickItem(item: WorkbenchTodoItem) { function handleClickItem(item: WorkbenchTodoItem) {
if (item.approvalBizType === 'overtime_application') {
openOvertimeDetail(item);
return;
}
if (!item.routeKey) return; if (!item.routeKey) return;
routerPushByKey(item.routeKey as RouteKey); routerPushByKey(item.routeKey as RouteKey);
} }
function findOvertimeApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId) {
return null;
}
return overtimeApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function openOvertimeDetail(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item);
if (!row) return;
currentOvertimeApplication.value = row;
overtimeDetailVisible.value = true;
}
function openOvertimeStatusLog(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item);
if (!row) 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;
}
async function handleOvertimeActionSubmit(reason: string | null) {
if (!currentOvertimeApplication.value) {
return;
}
overtimeActionSubmitting.value = true;
const result =
currentOvertimeActionType.value === 'approve'
? await fetchApproveOvertimeApplication(currentOvertimeApplication.value.id, { reason })
: await fetchRejectOvertimeApplication(currentOvertimeApplication.value.id, { reason });
overtimeActionSubmitting.value = false;
if (result.error) {
return;
}
overtimeActionVisible.value = false;
overtimeDetailVisible.value = false;
window.$message?.success(currentOvertimeActionType.value === 'approve' ? '加班申请已通过' : '加班申请已退回');
await loadOvertimeApprovalItems();
}
async function loadOvertimeApprovalItems() {
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending',
keyword: undefined,
applicantName: undefined,
approverId: undefined,
approverName: undefined,
overtimeDate: undefined,
createTime: undefined
});
if (error || !data) {
overtimeApprovalRows.value = [];
overtimeApprovalItems.value = [];
return;
}
overtimeApprovalRows.value = data.list;
overtimeApprovalItems.value = buildWorkbenchTodoItems(
data.list.map(item => ({
id: `overtime-application-${item.id}`,
category: 'approval',
title: `${item.applicantName} · ${item.overtimeDate.slice(5, 7)} 月加班 ${item.overtimeDuration} 申请待审批`,
createdTime: item.submitTime || item.createTime,
deadline: item.submitTime || item.createTime,
source: `加班申请 · ${item.applicantName}`,
priority: 'mid',
approvalBizType: 'overtime_application',
approvalBizId: item.id
}))
);
}
function getDeadlineToneClass(item: WorkbenchTodoItem) { function getDeadlineToneClass(item: WorkbenchTodoItem) {
if (isWorkbenchTodoOverdue(item)) return 'workbench-todo__deadline--rose'; if (isWorkbenchTodoOverdue(item)) return 'workbench-todo__deadline--rose';
if (item.remainingDays === 0) return 'workbench-todo__deadline--amber'; if (item.remainingDays === 0) return 'workbench-todo__deadline--amber';
return 'workbench-todo__deadline--slate'; return 'workbench-todo__deadline--slate';
} }
onMounted(loadOvertimeApprovalItems);
</script> </script>
<template> <template>
@@ -222,7 +382,19 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
{{ filter.label }} {{ filter.label }}
</button> </button>
</div> </div>
<div v-else></div> <div v-else class="workbench-todo__filters-left">
<button
v-for="tab in approvalBizTabs"
:key="tab.key"
type="button"
class="workbench-todo__filter"
:class="{ 'workbench-todo__filter--active': activeApprovalBizType === tab.key }"
@click="handleSelectApprovalBizType(tab.key)"
>
{{ tab.label }}
<span class="workbench-todo__filter-count">{{ approvalBizTabCounts[tab.key] }}</span>
</button>
</div>
<ElDropdown trigger="click" placement="bottom-end" @command="handleSelectSort"> <ElDropdown trigger="click" placement="bottom-end" @command="handleSelectSort">
<span class="workbench-todo__sort"> <span class="workbench-todo__sort">
@@ -250,7 +422,7 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
v-for="item in pagedItems" v-for="item in pagedItems"
:key="item.id" :key="item.id"
class="workbench-todo__item" class="workbench-todo__item"
:class="{ 'workbench-todo__item--clickable': Boolean(item.routeKey) }" :class="{ 'workbench-todo__item--clickable': Boolean(item.routeKey || item.approvalBizType) }"
@click="handleClickItem(item)" @click="handleClickItem(item)"
> >
<div class="workbench-todo__leading"> <div class="workbench-todo__leading">
@@ -268,6 +440,38 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</div> </div>
<div class="workbench-todo__trailing"> <div class="workbench-todo__trailing">
<div v-if="item.approvalBizType === 'overtime_application'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="详情">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openOvertimeDetail(item)">
<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" />
</ElButton>
</ElTooltip>
</div>
<span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)"> <span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)">
{{ item.deadlineLabel }} {{ item.deadlineLabel }}
</span> </span>
@@ -295,6 +499,18 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
:row-data="null" :row-data="null"
@submitted="handleAddSubmitted" @submitted="handleAddSubmitted"
/> />
<OvertimeApplicationDetailDialog v-model:visible="overtimeDetailVisible" :row-data="currentOvertimeApplication" />
<OvertimeApplicationStatusLogDialog
v-model:visible="overtimeStatusLogVisible"
:row-data="currentOvertimeApplication"
/>
<OvertimeApplicationActionDialog
v-model:visible="overtimeActionVisible"
:action-type="currentOvertimeActionType"
:loading="overtimeActionSubmitting"
@submit="handleOvertimeActionSubmit"
/>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
@@ -468,6 +684,12 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
color: rgb(190 18 60 / 96%); color: rgb(190 18 60 / 96%);
} }
.workbench-todo__filter-count {
margin-left: 4px;
font-size: 11px;
font-weight: 700;
}
.workbench-todo__content { .workbench-todo__content {
min-height: 400px; min-height: 400px;
display: flex; display: flex;
@@ -596,6 +818,25 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
.workbench-todo__trailing { .workbench-todo__trailing {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px;
}
.workbench-todo__actions {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.workbench-todo__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.workbench-todo__action-btn) {
min-width: auto;
height: auto;
padding: 3px;
line-height: 1;
} }
.workbench-todo__deadline { .workbench-todo__deadline {