feat(personal-item): 个人事项

This commit is contained in:
caozehui
2026-05-19 10:59:07 +08:00
parent acd41555f9
commit 7d578ab271
28 changed files with 3743 additions and 105 deletions

View File

@@ -126,29 +126,34 @@ export function setupElegantRouter() {
order: 0,
keepAlive: true
},
'personal-center_my-item': {
icon: 'mdi:checkbox-multiple-blank-circle-outline',
order: 1,
keepAlive: true
},
'personal-center_my-weekly': {
icon: 'mdi:calendar-week-outline',
order: 1,
order: 2,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
order: 2,
order: 3,
keepAlive: true
},
'personal-center_my-performance': {
icon: 'mdi:trophy-outline',
order: 3,
order: 4,
keepAlive: true
},
'personal-center_my-application': {
icon: 'mdi:file-document-outline',
order: 4,
order: 5,
keepAlive: true
},
'personal-center_pending-approval': {
icon: 'mdi:check-decagram-outline',
order: 5,
order: 6,
keepAlive: true
},
system: {

View File

@@ -1,46 +1,13 @@
{
"generatedAt": "2026-05-13T10:54:08.684Z",
"generatedAt": "2026-05-19T07:08:28.081Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": {
"directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 21,
"total": 22,
"items": [
{
"name": "workbench",
"path": "/workbench",
"component": "layout.base$view.workbench",
"title": "workbench",
"routeTitle": "workbench",
"i18nKey": "route.workbench",
"icon": "mdi:view-dashboard-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "workbench",
"i18nKey": "route.workbench",
"icon": "mdi:view-dashboard-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": null,
"pageType": "single",
"source": "generated"
},
{
"name": "product_list",
"path": "/product/list",
@@ -111,7 +78,7 @@
"name": "ticket_my-submitted",
"path": "/ticket/my-submitted",
"component": "view.ticket_my-submitted",
"title": "ticket_my-submitted",
"title": "我提交的工单",
"routeTitle": "ticket_my-submitted",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
@@ -125,7 +92,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "ticket_my-submitted",
"title": "我提交的工单",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
"localIcon": null,
@@ -144,7 +111,7 @@
"name": "ticket_my-pending",
"path": "/ticket/my-pending",
"component": "view.ticket_my-pending",
"title": "ticket_my-pending",
"title": "待我处理的工单",
"routeTitle": "ticket_my-pending",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
@@ -158,7 +125,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "ticket_my-pending",
"title": "待我处理的工单",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
"localIcon": null,
@@ -177,7 +144,7 @@
"name": "metrics_project-progress",
"path": "/metrics/project-progress",
"component": "view.metrics_project-progress",
"title": "metrics_project-progress",
"title": "项目进度",
"routeTitle": "metrics_project-progress",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
@@ -191,7 +158,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "metrics_project-progress",
"title": "项目进度",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
"localIcon": null,
@@ -210,7 +177,7 @@
"name": "metrics_member-efficiency",
"path": "/metrics/member-efficiency",
"component": "view.metrics_member-efficiency",
"title": "metrics_member-efficiency",
"title": "员工能效",
"routeTitle": "metrics_member-efficiency",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
@@ -224,7 +191,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "metrics_member-efficiency",
"title": "员工能效",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
"localIcon": null,
@@ -243,7 +210,7 @@
"name": "metrics_worktime",
"path": "/metrics/worktime",
"component": "view.metrics_worktime",
"title": "metrics_worktime",
"title": "工时统计",
"routeTitle": "metrics_worktime",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
@@ -257,7 +224,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "metrics_worktime",
"title": "工时统计",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
"localIcon": null,
@@ -272,11 +239,77 @@
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-profile",
"path": "/personal-center/my-profile",
"component": "view.personal-center_my-profile",
"title": "个人信息",
"routeTitle": "personal-center_my-profile",
"i18nKey": "route.personal-center_my-profile",
"icon": "mdi:account-box-outline",
"localIcon": null,
"order": 0,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "个人信息",
"i18nKey": "route.personal-center_my-profile",
"icon": "mdi:account-box-outline",
"localIcon": null,
"order": 0,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-item",
"path": "/personal-center/my-item",
"component": "view.personal-center_my-item",
"title": "我的事项",
"routeTitle": "personal-center_my-item",
"i18nKey": "route.personal-center_my-item",
"icon": "mdi:checkbox-multiple-blank-circle-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的事项",
"i18nKey": "route.personal-center_my-item",
"icon": "mdi:checkbox-multiple-blank-circle-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-weekly",
"path": "/personal-center/my-weekly",
"component": "view.personal-center_my-weekly",
"title": "personal-center_my-weekly",
"title": "我的周报",
"routeTitle": "personal-center_my-weekly",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
@@ -290,7 +323,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-weekly",
"title": "我的周报",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"localIcon": null,
@@ -309,7 +342,7 @@
"name": "personal-center_my-monthly",
"path": "/personal-center/my-monthly",
"component": "view.personal-center_my-monthly",
"title": "personal-center_my-monthly",
"title": "我的月报",
"routeTitle": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
@@ -323,7 +356,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-monthly",
"title": "我的月报",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
@@ -342,7 +375,7 @@
"name": "personal-center_my-performance",
"path": "/personal-center/my-performance",
"component": "view.personal-center_my-performance",
"title": "personal-center_my-performance",
"title": "我的绩效",
"routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
@@ -356,7 +389,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-performance",
"title": "我的绩效",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
@@ -375,7 +408,7 @@
"name": "personal-center_my-application",
"path": "/personal-center/my-application",
"component": "view.personal-center_my-application",
"title": "personal-center_my-application",
"title": "我的申请",
"routeTitle": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
@@ -389,7 +422,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-application",
"title": "我的申请",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
@@ -408,7 +441,7 @@
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "personal-center_pending-approval",
"title": "待我审批",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
@@ -422,7 +455,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_pending-approval",
"title": "待我审批",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
@@ -639,7 +672,7 @@
"name": "infra_state-machine",
"path": "/infra/state-machine",
"component": "view.infra_state-machine",
"title": "infra_state-machine",
"title": "状态机管理",
"routeTitle": "infra_state-machine",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
@@ -653,7 +686,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "infra_state-machine",
"title": "状态机管理",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
"localIcon": null,
@@ -672,7 +705,7 @@
"name": "infra_rd-code",
"path": "/infra/rd-code",
"component": "view.infra_rd-code",
"title": "infra_rd-code",
"title": "研发令号",
"routeTitle": "infra_rd-code",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
@@ -686,7 +719,7 @@
"redirect": null,
"props": null,
"meta": {
"title": "infra_rd-code",
"title": "研发令号",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,

View File

@@ -84,6 +84,14 @@ export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_typ
*/
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
/**
* 工作日志完成难度字典编码
*
* 对应业务字段:任务/个人事项工作日志中的 difficulty
* 来源口径:后端工作日志表 `rdms_task_worklog.difficulty` 字段注释明确使用字典 `rdms_worklog_difficulty`
*/
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_worklog_difficulty';
/**
* 需求允许删除的状态字典编码
*

View File

@@ -15,7 +15,8 @@ export type StatusDomain =
| 'project'
| 'product'
| 'requirement'
| 'workOrder';
| 'workOrder'
| 'personalItem';
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
// 项目-执行
@@ -53,7 +54,14 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
// 需求(待补全)
requirement: {},
// 工单(待补全)
workOrder: {}
workOrder: {},
// 个人事项
personalItem: {
pending: 'info',
active: 'primary',
completed: 'success',
cancelled: 'danger'
}
};
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
@@ -63,3 +71,7 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
return statusTagTypeRegistry[domain][statusCode] || 'info';
}
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('personalItem', statusCode);
}

View File

@@ -168,6 +168,7 @@ const local: App.I18n.Schema = {
metrics_worktime: 'Worktime',
'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_my-performance': 'My Performance',

View File

@@ -168,6 +168,7 @@ const local: App.I18n.Schema = {
metrics_worktime: '工时统计',
'personal-center': '个人中心',
'personal-center_my-profile': '个人信息',
'personal-center_my-item': '我的事项',
'personal-center_my-weekly': '我的周报',
'personal-center_my-monthly': '我的月报',
'personal-center_my-performance': '我的绩效',

View File

@@ -34,6 +34,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-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"),

View File

@@ -291,6 +291,18 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true
}
},
{
name: 'personal-center_my-item',
path: '/personal-center/my-item',
component: 'view.personal-center_my-item',
meta: {
title: 'personal-center_my-item',
i18nKey: 'route.personal-center_my-item',
icon: 'mdi:checkbox-multiple-blank-circle-outline',
order: 1,
keepAlive: true
}
},
{
name: 'personal-center_my-monthly',
path: '/personal-center/my-monthly',

View File

@@ -191,6 +191,7 @@ const routeMap: RouteMap = {
"metrics_worktime": "/metrics/worktime",
"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",

View File

@@ -1,7 +1,7 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { clearUserRouteCache } from './route';
import type { ServiceRequestResult } from './shared';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId } from './shared';
/** 后端登录返回 */
interface BackendLoginToken {
@@ -38,6 +38,14 @@ interface BackendMyProfileDetailDTO {
position?: Api.SystemManage.PostSimple | null;
}
interface BackendFileDTO {
id: string | number;
configId: string | number;
name?: string | null;
path: string;
url: string;
}
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
/** 将后端 token 结构转换成前端现有结构 */
@@ -187,6 +195,23 @@ export function fetchUpdateMyProfile(data: Api.Auth.UpdateMyProfileParams) {
}
/** 修改当前登录人密码 */
export async function fetchUpdateMyAvatar(file: File) {
const formData = new FormData();
formData.append('file', file);
const result = await request<BackendFileDTO>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-avatar`,
method: 'put',
data: formData
});
return mapServiceResult(result as ServiceRequestResult<BackendFileDTO>, data => ({
...data,
id: normalizeStringId(data.id),
configId: normalizeStringId(data.configId)
}));
}
export function fetchUpdateMyPassword(data: Api.Auth.UpdateMyPasswordParams) {
return request<boolean>({
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-password`,

View File

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

View File

@@ -0,0 +1,872 @@
import dayjs from 'dayjs';
import type { ConfigType } from 'dayjs';
import type { FlatResponseData } from '@sa/axios';
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ProjectExecutionResponse,
type TaskWorklogResponse,
normalizeProjectLocalDate,
normalizeTaskWorklog
} from './project-shared';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
type PersonalItemRecord = Api.PersonalItem.PersonalItem;
type PersonalItemWorklogRecord = Api.Project.TaskWorklog;
type PersonalItemResult<T> = Promise<FlatResponseData<any, T>>;
type StringIdResponse = string | number;
type PersonalItemLocalDateValue = string | number[] | null;
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: StringIdResponse;
id?: StringIdResponse;
};
type PersonalItemLifecycleActionResponse = Omit<Api.PersonalItem.PersonalItemLifecycleAction, 'needReason'> & {
needReason?: boolean | number | string | null;
};
type PersonalItemResponse = Omit<
Api.PersonalItem.PersonalItem,
| 'id'
| 'ownerId'
| 'terminal'
| 'allowEdit'
| 'availableActions'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'attachments'
| 'totalSpentHours'
> & {
id: StringIdResponse;
ownerId: StringIdResponse;
terminal?: boolean | number | string | null;
allowEdit?: boolean | number | string | null;
availableActions?: PersonalItemLifecycleActionResponse[] | null;
plannedStartDate?: PersonalItemLocalDateValue;
plannedEndDate?: PersonalItemLocalDateValue;
actualStartDate?: PersonalItemLocalDateValue;
actualEndDate?: PersonalItemLocalDateValue;
attachments?: AttachmentItemResponse[] | null;
progressRate?: number | null;
totalSpentHours?: number | string | null;
};
type PersonalItemPageResponse = Omit<Api.PersonalItem.PersonalItemPageResult, 'total' | 'list'> & {
total: number | string;
list: PersonalItemResponse[];
};
type PersonalItemWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
type PersonalItemExecutionOptionResponse = ProjectExecutionResponse & {
projectName?: string | null;
};
type PersonalItemSaveRequest = {
executionId?: string;
taskTitle: string;
progressRate?: number;
plannedStartDate?: string;
plannedEndDate?: string;
taskDesc?: string;
attachments?: Array<{
id?: string;
url: string;
name: string;
size?: number;
contentType?: string;
}>;
};
type PersonalItemWorklogSaveRequest = {
startDate: string;
endDate: string;
durationHours: number;
progressRate: number;
workContent?: string;
attachments?: Array<{
id?: string;
url: string;
name: string;
size?: number;
contentType?: string;
}>;
difficulty?: string;
};
const PERSONAL_ITEM_PREFIX = `${WEB_SERVICE_PREFIX}/project/personal-items`;
const CURRENT_USER_ID = 'current-user';
const CURRENT_USER_NAME = '当前用户';
const personalItems: PersonalItemRecord[] = createSeedItems();
const personalItemWorklogs: PersonalItemWorklogRecord[] = createSeedWorklogs();
const executionOptions: Api.PersonalItem.PersonalItemExecutionOption[] = createExecutionOptions();
function createSuccessResult<T>(data: T): PersonalItemResult<T> {
return Promise.resolve({
data,
error: null,
response: undefined
} as unknown as FlatResponseData<any, T>);
}
function normalizePageTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
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();
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
return false;
}
return true;
}
return false;
}
function normalizeLifecycleActions(
actions?: PersonalItemLifecycleActionResponse[] | null
): Api.PersonalItem.PersonalItemLifecycleAction[] {
return (actions ?? []).map(action => ({
actionCode: action.actionCode,
actionName: action.actionName ?? '',
needReason: normalizeBooleanFlag(action.needReason)
}));
}
function normalizePersonalItem(response: PersonalItemResponse): Api.PersonalItem.PersonalItem {
return {
id: normalizeStringId(response.id),
taskTitle: response.taskTitle ?? '',
ownerId: normalizeStringId(response.ownerId),
statusCode: response.statusCode,
terminal: normalizeBooleanFlag(response.terminal),
allowEdit: normalizeBooleanFlag(response.allowEdit),
availableActions: normalizeLifecycleActions(response.availableActions),
progressRate:
typeof response.progressRate === 'number' ? response.progressRate : Number(response.progressRate ?? 0),
totalSpentHours: (() => {
if (typeof response.totalSpentHours === 'number') {
return response.totalSpentHours;
}
if (response.totalSpentHours === null || response.totalSpentHours === undefined) {
return null;
}
return Number(response.totalSpentHours);
})(),
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null,
attachments: normalizeAttachments(response.attachments),
creator: response.creator ?? '',
createTime: response.createTime ?? '',
updater: response.updater ?? '',
updateTime: response.updateTime ?? '',
deleted: Boolean(response.deleted),
ownerName: response.ownerName ?? null,
ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null
};
}
function normalizePersonalItemExecutionOption(
response: PersonalItemExecutionOptionResponse
): Api.PersonalItem.PersonalItemExecutionOption {
return {
executionId: normalizeStringId(response.id),
executionName: response.executionName ?? '',
projectId: normalizeStringId(response.projectId),
projectName: response.projectName ?? null
};
}
function toPersonalItemSaveRequest(data: Api.PersonalItem.SavePersonalItemParams): PersonalItemSaveRequest {
return {
executionId: data.executionId ?? undefined,
taskTitle: data.taskTitle.trim(),
progressRate: typeof data.progressRate === 'number' ? data.progressRate : undefined,
plannedStartDate: data.plannedStartDate ?? undefined,
plannedEndDate: data.plannedEndDate ?? undefined,
taskDesc: data.taskDesc ?? undefined,
attachments:
data.attachments?.map(item => ({
id: item.fileId || undefined,
url: item.url,
name: item.name,
size: item.size,
contentType: item.contentType
})) ?? undefined
};
}
function toPersonalItemWorklogSaveRequest(
data: Api.PersonalItem.SavePersonalItemWorklogParams
): PersonalItemWorklogSaveRequest {
return {
startDate: data.startDate,
endDate: data.endDate,
durationHours: Number(data.durationHours.toFixed(1)),
progressRate: Number(data.progressRate.toFixed(2)),
workContent: data.workContent ?? undefined,
attachments:
data.attachments?.map(item => ({
id: item.fileId || undefined,
url: item.url,
name: item.name,
size: item.size,
contentType: item.contentType
})) ?? undefined,
difficulty: data.difficulty ?? undefined
};
}
function createPersonalItemPageQuery(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
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.ownerId) {
query.append('ownerId', params.ownerId);
}
if (params.statusCode) {
query.append('statusCode', params.statusCode);
}
params.updateTime?.forEach(item => {
if (item) {
query.append('updateTime', item);
}
});
return query.toString();
}
function createIdsQuery(ids: string[]) {
const query = new URLSearchParams();
ids.forEach(id => {
if (id) {
query.append('ids', id);
}
});
return query.toString();
}
function createBindExecutionQuery(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
const query = new URLSearchParams();
payload.ids.forEach(id => {
if (id) {
query.append('itemIds', id);
}
});
query.append('executionId', payload.executionId);
return query.toString();
}
function cloneAttachment(item: Api.Project.AttachmentItem): Api.Project.AttachmentItem {
return { ...item };
}
function cloneItem(item: PersonalItemRecord): PersonalItemRecord {
return {
...item,
attachments: item.attachments?.map(cloneAttachment) ?? null
};
}
function cloneWorklog(item: PersonalItemWorklogRecord): PersonalItemWorklogRecord {
return {
...item,
attachments: item.attachments?.map(cloneAttachment) ?? null
};
}
function normalizeDateTime(value?: ConfigType | null) {
const target = value ? dayjs(value) : dayjs();
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD HH:mm:ss');
}
function normalizeDate(value?: ConfigType | null) {
if (!value) {
return null;
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD') : null;
}
function createSeedItems(): PersonalItemRecord[] {
const now = dayjs();
return [
{
id: 'personal-item-1',
taskTitle: '整理供应商沟通纪要',
ownerId: CURRENT_USER_ID,
statusCode: 'active',
progressRate: 45,
plannedStartDate: normalizeDate(now.subtract(3, 'day')),
plannedEndDate: normalizeDate(now.add(2, 'day')),
actualStartDate: normalizeDate(now.subtract(2, 'day')),
actualEndDate: null,
taskDesc: '<p>补齐今天会议纪要,沉淀成一页内部记录,便于后续同步。</p>',
lastStatusReason: null,
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(3, 'day').hour(9).minute(20).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(2, 'hour')),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '进行中'
},
{
id: 'personal-item-2',
taskTitle: '清理浏览器收藏夹里的项目入口',
ownerId: CURRENT_USER_ID,
statusCode: 'pending',
progressRate: 0,
plannedStartDate: normalizeDate(now.add(1, 'day')),
plannedEndDate: normalizeDate(now.add(4, 'day')),
actualStartDate: null,
actualEndDate: null,
taskDesc: '<p>把已经废弃的测试环境、旧文档入口统一清理。</p>',
lastStatusReason: null,
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(2, 'day').hour(14).minute(10).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(5, 'hour')),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '待处理'
},
{
id: 'personal-item-3',
taskTitle: '补充账号开通说明截图',
ownerId: CURRENT_USER_ID,
statusCode: 'completed',
progressRate: 100,
plannedStartDate: normalizeDate(now.subtract(5, 'day')),
plannedEndDate: normalizeDate(now.subtract(2, 'day')),
actualStartDate: normalizeDate(now.subtract(5, 'day')),
actualEndDate: normalizeDate(now.subtract(1, 'day')),
taskDesc: '<p>为新同事入职说明补一版截图,后续发在群公告。</p>',
lastStatusReason: '已完成并同步团队',
attachments: null,
creator: CURRENT_USER_NAME,
createTime: normalizeDateTime(now.subtract(5, 'day').hour(11).minute(0).second(0)),
updater: CURRENT_USER_NAME,
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(30).second(0)),
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: '已完成'
}
];
}
function createSeedWorklogs(): PersonalItemWorklogRecord[] {
const now = dayjs();
return [
{
id: 'worklog-1',
taskId: 'personal-item-1',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(2, 'day'))!,
endDate: normalizeDate(now.subtract(2, 'day'))!,
durationHours: 2.5,
progressRate: 30,
difficulty: '2',
workContent: '整理会议录音和重点结论,先输出初版纪要。',
attachments: null,
createTime: normalizeDateTime(now.subtract(2, 'day').hour(19)),
updateTime: normalizeDateTime(now.subtract(2, 'day').hour(19))
},
{
id: 'worklog-2',
taskId: 'personal-item-1',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(1, 'day'))!,
endDate: normalizeDate(now.subtract(1, 'day'))!,
durationHours: 1.5,
progressRate: 45,
difficulty: '2',
workContent: '补全供应商待确认项并整理后续跟进人。',
attachments: null,
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18)),
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18))
},
{
id: 'worklog-3',
taskId: 'personal-item-3',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(5, 'day'))!,
endDate: normalizeDate(now.subtract(5, 'day'))!,
durationHours: 1,
progressRate: 60,
difficulty: '1',
workContent: '补拍账号开通流程截图。',
attachments: null,
createTime: normalizeDateTime(now.subtract(5, 'day').hour(15)),
updateTime: normalizeDateTime(now.subtract(5, 'day').hour(15))
},
{
id: 'worklog-4',
taskId: 'personal-item-3',
userId: CURRENT_USER_ID,
userNickname: CURRENT_USER_NAME,
startDate: normalizeDate(now.subtract(1, 'day'))!,
endDate: normalizeDate(now.subtract(1, 'day'))!,
durationHours: 0.5,
progressRate: 100,
difficulty: '1',
workContent: '校对文案并发到群公告。',
attachments: null,
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20)),
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20))
}
];
}
function createExecutionOptions(): Api.PersonalItem.PersonalItemExecutionOption[] {
return [
{
executionId: 'execution-1001',
executionName: '2026Q2 运营提效',
projectId: 'project-1001',
projectName: '运营中台优化'
},
{
executionId: 'execution-1002',
executionName: '2026Q2 用户支持专项',
projectId: 'project-1002',
projectName: '基础平台升级'
},
{
executionId: 'execution-1003',
executionName: '2026Q3 数据治理',
projectId: 'project-1003',
projectName: '数据资产规范化'
}
];
}
function findItemIndex(id: string) {
return personalItems.findIndex(item => item.id === id);
}
function getItemOrThrow(id: string) {
const item = personalItems.find(current => current.id === id && !current.deleted);
if (!item) {
throw new Error(`personal item not found: ${id}`);
}
return item;
}
function sortItems(list: PersonalItemRecord[]) {
return [...list].sort((left, right) => dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf());
}
function sortWorklogs(list: PersonalItemWorklogRecord[]) {
return [...list].sort((left, right) => {
const endDiff = dayjs(right.endDate).valueOf() - dayjs(left.endDate).valueOf();
if (endDiff !== 0) {
return endDiff;
}
return dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf();
});
}
function getPersonalItemStatusName(statusCode: Api.PersonalItem.PersonalItemStatusCode) {
const statusNameMap: Partial<Record<Api.PersonalItem.PersonalItemStatusCode, string>> = {
pending: '待处理',
active: '进行中',
completed: '已完成'
};
return statusNameMap[statusCode] || statusCode;
}
function removeItemsByIds(ids: string[]) {
const idSet = new Set(ids);
for (let i = personalItems.length - 1; i >= 0; i -= 1) {
if (idSet.has(personalItems[i].id)) {
personalItems.splice(i, 1);
}
}
for (let i = personalItemWorklogs.length - 1; i >= 0; i -= 1) {
if (idSet.has(personalItemWorklogs[i].taskId)) {
personalItemWorklogs.splice(i, 1);
}
}
}
function sumWorklogHours(logs: PersonalItemWorklogRecord[]) {
return logs.reduce((sum, log) => sum + (log.durationHours ?? 0), 0);
}
function syncItemFromWorklogs(itemId: string) {
const item = getItemOrThrow(itemId);
const logs = sortWorklogs(personalItemWorklogs.filter(log => log.taskId === itemId));
item.statusName = getPersonalItemStatusName(item.statusCode);
item.totalSpentHours = sumWorklogHours(logs);
if (logs.length === 0) {
if (item.statusCode !== 'completed') {
item.progressRate = 0;
item.actualStartDate = null;
item.actualEndDate = null;
}
return;
}
const latestLog = logs[0];
const chronologicalLogs = [...logs].sort(
(left, right) => dayjs(left.startDate).valueOf() - dayjs(right.startDate).valueOf()
);
item.progressRate = latestLog.progressRate ?? item.progressRate;
item.actualStartDate = chronologicalLogs[0]?.startDate ?? item.actualStartDate;
item.actualEndDate = latestLog.endDate ?? item.actualEndDate;
item.updateTime = latestLog.updateTime;
item.updater = CURRENT_USER_NAME;
if (item.statusCode === 'pending') {
item.statusCode = 'active';
item.statusName = getPersonalItemStatusName(item.statusCode);
}
}
function applySaveFields(target: PersonalItemRecord, payload: Api.PersonalItem.SavePersonalItemParams) {
target.taskTitle = payload.taskTitle.trim();
target.ownerId = payload.ownerId || target.ownerId;
target.ownerName = CURRENT_USER_NAME;
target.plannedStartDate = payload.plannedStartDate;
target.plannedEndDate = payload.plannedEndDate;
target.taskDesc = payload.taskDesc ?? null;
target.attachments = payload.attachments?.map(cloneAttachment) ?? null;
target.updater = CURRENT_USER_NAME;
target.updateTime = normalizeDateTime();
}
function filterWorklogs(taskId: string, params?: Api.PersonalItem.PersonalItemWorklogSearchParams) {
return sortWorklogs(
personalItemWorklogs.filter(item => {
if (item.taskId !== taskId) {
return false;
}
if (params?.userId && item.userId !== params.userId) {
return false;
}
if (params?.startDate && dayjs(item.endDate).isBefore(dayjs(params.startDate), 'day')) {
return false;
}
if (params?.endDate && dayjs(item.startDate).isAfter(dayjs(params.endDate), 'day')) {
return false;
}
return true;
})
);
}
export async function fetchGetPersonalItemPage(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
const query = createPersonalItemPageQuery(params);
const result = await request<PersonalItemPageResponse>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/page?${query}` : `${PERSONAL_ITEM_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemPageResponse>, data => ({
total: normalizePageTotal(data.total),
list: data.list.map(normalizePersonalItem)
}));
}
export async function fetchGetPersonalItemDetail(id: string) {
const result = await request<PersonalItemResponse>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${id}`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemResponse>, normalizePersonalItem);
}
export async function fetchCreatePersonalItem(data: Api.PersonalItem.SavePersonalItemParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: PERSONAL_ITEM_PREFIX,
method: 'post',
data: toPersonalItemSaveRequest(data)
});
const mapped = mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
if (!mapped.error && mapped.data) {
const now = normalizeDateTime();
const createdItem: PersonalItemRecord = {
id: mapped.data,
taskTitle: data.taskTitle.trim(),
ownerId: data.ownerId || CURRENT_USER_ID,
statusCode: 'pending',
progressRate: typeof data.progressRate === 'number' ? data.progressRate : 0,
plannedStartDate: data.plannedStartDate,
plannedEndDate: data.plannedEndDate,
actualStartDate: null,
actualEndDate: null,
taskDesc: data.taskDesc ?? null,
lastStatusReason: null,
attachments: data.attachments?.map(cloneAttachment) ?? null,
creator: CURRENT_USER_NAME,
createTime: now,
updater: CURRENT_USER_NAME,
updateTime: now,
deleted: false,
ownerName: CURRENT_USER_NAME,
statusName: getPersonalItemStatusName('pending')
};
personalItems.unshift(createdItem);
}
return mapped;
}
export async function fetchUpdatePersonalItem(data: Api.PersonalItem.UpdatePersonalItemParams) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${data.id}`,
method: 'put',
data: toPersonalItemSaveRequest(data)
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
const targetIndex = findItemIndex(data.id);
if (targetIndex >= 0) {
applySaveFields(personalItems[targetIndex], data);
}
}
return mapped;
}
export async function fetchChangePersonalItemStatus(id: string, data: Api.PersonalItem.ChangePersonalItemStatusParams) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${id}/change-status`,
method: 'post',
data: {
actionCode: data.actionCode,
reason: data.reason ?? undefined
}
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
const target = personalItems.find(item => item.id === id);
if (target) {
target.lastStatusReason = data.reason ?? null;
target.updater = CURRENT_USER_NAME;
target.updateTime = normalizeDateTime();
if (data.actionCode === 'start') {
target.statusCode = 'active';
target.statusName = getPersonalItemStatusName('active');
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = null;
} else if (data.actionCode === 'complete') {
target.statusCode = 'completed';
target.statusName = getPersonalItemStatusName('completed');
target.progressRate = 100;
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = normalizeDate(dayjs());
} else if (data.actionCode === 'reopen') {
target.statusCode = 'active';
target.statusName = getPersonalItemStatusName('active');
target.actualStartDate ??= normalizeDate(dayjs());
target.actualEndDate = null;
}
}
}
return mapped;
}
export async function fetchDeletePersonalItem(id: string) {
const result = await request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/delete`,
method: 'delete',
params: { id }
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
removeItemsByIds([id]);
}
return mapped;
}
export async function fetchBatchDeletePersonalItems(payload: Api.PersonalItem.BatchDeletePersonalItemParams) {
const query = createIdsQuery(payload.ids);
const result = await request<boolean>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/delete-list?${query}` : `${PERSONAL_ITEM_PREFIX}/delete-list`,
method: 'delete'
});
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
if (!mapped.error && mapped.data) {
removeItemsByIds(payload.ids);
}
return mapped;
}
export async function fetchGetPersonalItemExecutionOptions() {
const result = await request<PersonalItemExecutionOptionResponse[]>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/owner/all-execution`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemExecutionOptionResponse[]>, data =>
data.map(normalizePersonalItemExecutionOption)
);
}
export async function fetchBindPersonalItemsToExecution(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
const query = createBindExecutionQuery(payload);
const result = await request<boolean>({
...safeJsonRequestConfig,
url: query ? `${PERSONAL_ITEM_PREFIX}/relate-execution?${query}` : `${PERSONAL_ITEM_PREFIX}/relate-execution`,
method: 'post'
});
return mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
}
export function fetchStartPersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'start' }) as PersonalItemResult<boolean>;
}
export function fetchCompletePersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'complete' }) as PersonalItemResult<boolean>;
}
export function fetchReopenPersonalItem(id: string): PersonalItemResult<boolean> {
return fetchChangePersonalItemStatus(id, { actionCode: 'reopen' }) as PersonalItemResult<boolean>;
}
export async function fetchGetPersonalItemWorklogPage(
taskId: string,
params: Api.PersonalItem.PersonalItemWorklogSearchParams = {}
) {
const result = await request<PersonalItemWorklogPageResponse>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<PersonalItemWorklogPageResponse>, data => ({
...data,
list: data.list.map(normalizeTaskWorklog)
}));
}
export async function fetchCreatePersonalItemWorklog(
taskId: string,
data: Api.PersonalItem.SavePersonalItemWorklogParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
method: 'post',
data: toPersonalItemWorklogSaveRequest(data)
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdatePersonalItemWorklog(
taskId: string,
payload: { worklogId: string; data: Api.PersonalItem.SavePersonalItemWorklogParams }
): PersonalItemResult<boolean> {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${payload.worklogId}`,
method: 'put',
data: toPersonalItemWorklogSaveRequest(payload.data)
});
}
export function fetchDeletePersonalItemWorklog(taskId: string, worklogId: string): PersonalItemResult<boolean> {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${worklogId}`,
method: 'delete'
});
}

View File

@@ -1,8 +0,0 @@
// 工作台聚合接口尚未开通,当前页面使用 src/views/workbench/mock.ts 的本地假数据。
// 接口契约确认后,在此处补:
// - fetchGetWorkbenchSummary (Banner 摘要 + KPI)
// - fetchGetWorkbenchTodos (我的待办)
// - fetchGetWorkbenchActivity (最近动态)
// - fetchGetWorkbenchProjects (我参与的项目)
// 全部走 src/service/request/index.ts 的统一实例,并保持 ID 字符串口径。
export {};

97
src/typings/api/personal-item.d.ts vendored Normal file
View File

@@ -0,0 +1,97 @@
declare namespace Api {
namespace PersonalItem {
interface PageParams {
pageNo: number;
pageSize: number;
}
type PersonalItemStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
interface PersonalItemLifecycleAction {
actionCode: string;
actionName: string;
needReason: boolean;
}
interface PersonalItem {
id: string;
taskTitle: string;
ownerId: string;
statusCode: PersonalItemStatusCode;
terminal?: boolean;
allowEdit?: boolean;
availableActions?: PersonalItemLifecycleAction[] | null;
progressRate: number;
totalSpentHours?: number | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
actualStartDate: string | null;
actualEndDate: string | null;
taskDesc: string | null;
lastStatusReason: string | null;
attachments: Api.Project.AttachmentItem[] | null;
creator: string;
createTime: string;
updater: string;
updateTime: string;
deleted: boolean;
ownerName?: string | null;
ownerNickname?: string | null;
statusName?: string | null;
}
type PersonalItemSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
ownerId: string;
statusCode: PersonalItemStatusCode;
updateTime: string[];
}
>;
interface PersonalItemPageResult {
total: number;
list: PersonalItem[];
}
interface SavePersonalItemParams {
taskTitle: string;
ownerId?: string;
executionId?: string | null;
progressRate?: number | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
attachments: Api.Project.AttachmentItem[] | null;
}
interface UpdatePersonalItemParams extends SavePersonalItemParams {
id: string;
}
interface ChangePersonalItemStatusParams {
actionCode: string;
reason?: string | null;
}
interface PersonalItemExecutionOption {
executionId: string;
executionName: string;
projectId?: string | null;
projectName?: string | null;
}
interface BatchDeletePersonalItemParams {
ids: string[];
}
interface BindPersonalItemExecutionParams {
ids: string[];
executionId: string;
}
type PersonalItemWorklog = Api.Project.TaskWorklog;
type PersonalItemWorklogSearchParams = Api.Project.TaskWorklogSearchParams;
type SavePersonalItemWorklogParams = Api.Project.SaveTaskWorklogParams;
}
}

View File

@@ -380,6 +380,7 @@ declare namespace Api {
durationHours: number;
/** 本次填报进度0~100scale=2 */
progressRate: number;
difficulty?: string | null;
workContent: string | null;
attachments?: AttachmentItem[] | null;
createTime: string;
@@ -403,6 +404,7 @@ declare namespace Api {
durationHours: number;
/** 本次填报进度0~100scale=2必填 */
progressRate: number;
difficulty?: string | null;
workContent?: string | null;
/** 编辑语义null 保留原值 / [] 清空 / [...] 替换 */
attachments?: AttachmentItem[] | null;

View File

@@ -151,6 +151,7 @@ declare module 'vue' {
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']

View File

@@ -45,6 +45,7 @@ declare module "@elegant-router/types" {
"metrics_worktime": "/metrics/worktime";
"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";
@@ -181,6 +182,7 @@ declare module "@elegant-router/types" {
| "metrics_project-progress"
| "metrics_worktime"
| "personal-center_my-application"
| "personal-center_my-item"
| "personal-center_my-monthly"
| "personal-center_my-performance"
| "personal-center_my-profile"

View File

@@ -0,0 +1,667 @@
<script setup lang="tsx">
import { computed, markRaw, nextTick, onActivated, reactive, ref } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import { useBoolean } from '@sa/hooks';
import {
fetchBatchDeletePersonalItems,
fetchBindPersonalItemsToExecution,
fetchChangePersonalItemStatus,
fetchDeletePersonalItem,
fetchGetPersonalItemDetail,
fetchGetPersonalItemPage
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import PersonalItemBindExecutionDialog from './modules/personal-item-bind-execution-dialog.vue';
import PersonalItemDetailDialog from './modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from './modules/personal-item-operate-dialog.vue';
import PersonalItemSearch from './modules/personal-item-search.vue';
import PersonalItemStatusActionDialog from './modules/personal-item-status-action-dialog.vue';
import {
formatPersonalItemDateRange,
formatPersonalItemDateTime,
formatPersonalItemOwnerName,
formatPersonalItemProgress,
getPersonalItemStatusLabel,
resolvePersonalItemStatusTagType
} from './modules/personal-item-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiPlay from '~icons/mdi/play';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'MyItem' });
type DetailTab = 'worklog';
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface PersonalItemRowAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'warning' | 'danger';
disabled?: boolean;
onClick: () => void | Promise<void>;
}
const lifecycleActionIconMap: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
reopen: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
const lifecycleActionTypeMap: Record<string, PersonalItemRowAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary',
reopen: 'primary',
start: 'primary'
};
const lifecycleActionOrder: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4,
reopen: 5,
start: 6
};
const authStore = useAuthStore();
const currentUserId = computed(() => {
const rawUserId = authStore.userInfo.userId;
return rawUserId ? String(rawUserId) : '';
});
function getInitSearchParams(): Api.PersonalItem.PersonalItemSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
ownerId: currentUserId.value || undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetPersonalItemPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const tableRef = ref<TableInstance>();
const checkedRowIds = ref<string[]>([]);
const bindExecutionSubmitting = ref(false);
const selectedCount = computed(() => checkedRowIds.value.length);
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetPersonalItemPage(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: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'taskTitle',
label: '事项标题',
minWidth: 260,
showOverflowTooltip: true
},
{
prop: 'ownerName',
label: '负责人',
minWidth: 140,
formatter: row => formatPersonalItemOwnerName(row)
},
{
prop: 'statusCode',
label: '状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={resolvePersonalItemStatusTagType(row.statusCode)}>
{getPersonalItemStatusLabel(row.statusCode)}
</ElTag>
)
},
{
prop: 'progressRate',
label: '进度',
width: 100,
align: 'center',
formatter: row => formatPersonalItemProgress(row.progressRate)
},
{
prop: 'plannedDateRange',
label: '计划日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.plannedStartDate, row.plannedEndDate)
},
{
prop: 'actualDateRange',
label: '实际日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.actualStartDate, row.actualEndDate)
},
{
prop: 'updateTime',
label: '最近更新',
minWidth: 180,
formatter: row => formatPersonalItemDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 240,
align: 'center',
fixed: 'right',
formatter: row => renderRowActions(row)
}
]
});
const { bool: operateVisible, setTrue: openOperateDialog, setFalse: closeOperateDialog } = useBoolean();
const { bool: detailVisible, setTrue: openDetailDialog } = useBoolean();
const {
bool: bindExecutionVisible,
setTrue: openBindExecutionDialog,
setFalse: closeBindExecutionDialog
} = useBoolean();
const { bool: statusActionVisible, setTrue: openStatusActionDialog, setFalse: closeStatusActionDialog } = useBoolean();
const operateType = ref<PersonalItemOperateType>('add');
const editingData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailDefaultTab = ref<DetailTab>('worklog');
const currentStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
const currentStatusItem = ref<Api.PersonalItem.PersonalItem | null>(null);
async function openDetail(row: Api.PersonalItem.PersonalItem, defaultTab: DetailTab = 'worklog') {
const { error, data: latestDetail } = await fetchGetPersonalItemDetail(row.id);
detailData.value = error || !latestDetail ? row : latestDetail;
detailDefaultTab.value = defaultTab;
openDetailDialog();
}
function openView(row: Api.PersonalItem.PersonalItem) {
operateType.value = 'view';
editingData.value = row;
openOperateDialog();
}
// function createLifecycleAction(
// fallback: {
// key: string;
// tooltip: string;
// icon: object;
// type: PersonalItemRowAction['type'];
// actionCode: string;
// },
// action: Api.PersonalItem.PersonalItemLifecycleAction | null
// ): PersonalItemRowAction {
// return {
// key: fallback.key,
// tooltip: action?.actionName ?? fallback.tooltip,
// icon: fallback.icon,
// type: fallback.type,
// disabled: !action,
// onClick: async () =>
// handleStatusAction(currentStatusItem.value!, {
// actionCode: action?.actionCode ?? fallback.actionCode,
// actionName: action?.actionName ?? fallback.tooltip,
// needReason: action?.needReason ?? false
// })
// };
// }
function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAction[] {
currentStatusItem.value = row;
const rawLifecycleActions = [...(row.availableActions ?? [])];
const pauseAction = rawLifecycleActions.find(action => action.actionCode === 'pause') ?? null;
const cancelAction = rawLifecycleActions.find(action => action.actionCode === 'cancel') ?? null;
const completeAction = rawLifecycleActions.find(action => action.actionCode === 'complete') ?? null;
const lifecycleActions = rawLifecycleActions
.filter(action => !['pause', 'cancel', 'complete'].includes(action.actionCode))
.sort(
(left, right) => (lifecycleActionOrder[left.actionCode] ?? 99) - (lifecycleActionOrder[right.actionCode] ?? 99)
)
.map(action => ({
key: `status-${action.actionCode}`,
tooltip: action.actionName,
icon: markRaw(lifecycleActionIconMap[action.actionCode] ?? IconMdiSync),
type: lifecycleActionTypeMap[action.actionCode] ?? 'primary',
onClick: async () => handleStatusAction(row, action)
}));
return [
{
key: 'worklog',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: async () => openDetail(row, 'worklog')
},
{
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: async () => {
operateType.value = 'edit';
editingData.value = row;
openOperateDialog();
}
},
{
key: 'status-pause',
tooltip: pauseAction?.actionName ?? '暂停',
icon: markRaw(IconMdiPause),
type: 'warning',
disabled: !pauseAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: pauseAction?.actionCode ?? 'pause',
actionName: pauseAction?.actionName ?? '暂停',
needReason: pauseAction?.needReason ?? false
})
},
{
key: 'status-cancel',
tooltip: cancelAction?.actionName ?? '取消',
icon: markRaw(IconMdiCloseCircleOutline),
type: 'danger',
disabled: !cancelAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: cancelAction?.actionCode ?? 'cancel',
actionName: cancelAction?.actionName ?? '取消',
needReason: cancelAction?.needReason ?? false
})
},
...lifecycleActions,
{
key: 'status-complete',
tooltip: completeAction?.actionName ?? '完成',
icon: markRaw(IconMdiCheckCircleOutline),
type: 'success',
disabled: !completeAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: completeAction?.actionCode ?? 'complete',
actionName: completeAction?.actionName ?? '完成',
needReason: completeAction?.needReason ?? false
})
},
{
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: async () => handleDelete(row)
}
];
}
function renderRowActions(row: Api.PersonalItem.PersonalItem) {
return (
<div class="personal-item-row-actions" onClick={event => event.stopPropagation()}>
{buildRowActions(row).map(action => {
const Icon = action.icon as any;
return (
<ElTooltip key={action.key} content={action.tooltip}>
<span class="inline-flex">
<ElButton
link
type={action.type}
class="personal-item-row-action-btn"
disabled={action.disabled}
onClick={event => {
event.stopPropagation();
if (action.disabled) {
return;
}
action.onClick();
}}
>
<Icon class="text-15px" />
</ElButton>
</span>
</ElTooltip>
);
})}
</div>
);
}
function openAdd() {
operateType.value = 'add';
editingData.value = null;
openOperateDialog();
}
function handleSelectionChange(rows: Api.PersonalItem.PersonalItem[]) {
checkedRowIds.value = rows.map(item => item.id);
}
function resolveReloadPageAfterRemove() {
const currentPage = searchParams.pageNo ?? 1;
if (currentPage > 1 && data.value.length > 0 && checkedRowIds.value.length >= data.value.length) {
return currentPage - 1;
}
return currentPage;
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
checkedRowIds.value = [];
await getDataByPage(page);
await nextTick();
tableRef.value?.clearSelection();
}
function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
closeOperateDialog();
reloadTable(searchParams.pageNo ?? 1);
}
function handleDetailChanged(latestItem: Api.PersonalItem.PersonalItem) {
detailData.value = latestItem;
const targetIndex = data.value.findIndex(item => item.id === latestItem.id);
if (targetIndex >= 0) {
data.value.splice(targetIndex, 1, latestItem);
}
}
function handleStatusAction(row: Api.PersonalItem.PersonalItem, action: Api.PersonalItem.PersonalItemLifecycleAction) {
currentStatusItem.value = row;
currentStatusAction.value = action;
openStatusActionDialog();
}
async function handleStatusActionSubmit(reason: string | null) {
if (!currentStatusItem.value || !currentStatusAction.value) {
return;
}
const { error } = await fetchChangePersonalItemStatus(currentStatusItem.value.id, {
actionCode: currentStatusAction.value.actionCode,
reason
});
if (error) {
return;
}
closeStatusActionDialog();
window.$message?.success(`${currentStatusAction.value.actionName}成功`);
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleDelete(row: Api.PersonalItem.PersonalItem) {
try {
await ElMessageBox.confirm(`确定删除个人事项“${row.taskTitle}”吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const { error } = await fetchDeletePersonalItem(row.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleBatchDelete() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
try {
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条个人事项吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBatchDeletePersonalItems({ ids: [...checkedRowIds.value] });
if (error) {
return;
}
window.$message?.success('批量删除成功');
await reloadTable(targetPage);
}
function handleOpenBindExecution() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
openBindExecutionDialog();
}
async function handleBindExecutionSubmit(payload: { executionId: string }) {
bindExecutionSubmitting.value = true;
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBindPersonalItemsToExecution({
ids: [...checkedRowIds.value],
executionId: payload.executionId
});
bindExecutionSubmitting.value = false;
if (error) {
return;
}
closeBindExecutionDialog();
window.$message?.success('批量关联执行成功');
await reloadTable(targetPage);
}
onActivated(() => {
searchParams.ownerId = currentUserId.value || undefined;
reloadTable(searchParams.pageNo ?? 1);
});
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<PersonalItemSearch 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 items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>个人事项</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain type="danger" :disabled="selectedCount === 0" @click="handleBatchDelete">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
批量删除
</ElButton>
<ElButton plain :disabled="selectedCount === 0" @click="handleOpenBindExecution">
<template #icon>
<icon-mdi-link-variant 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
ref="tableRef"
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-if="col.prop === 'taskTitle'" v-bind="col">
<template #default="{ row }">
<ElButton link type="primary" class="personal-item-title-link" @click.stop="openView(row)">
{{ row.taskTitle || '--' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn v-else 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>
<PersonalItemOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="handleSubmitted"
/>
<PersonalItemDetailDialog
v-model:visible="detailVisible"
:row-data="detailData"
:default-tab="detailDefaultTab"
@changed="handleDetailChanged"
/>
<PersonalItemBindExecutionDialog
v-model:visible="bindExecutionVisible"
:selected-count="selectedCount"
:submit-loading="bindExecutionSubmitting"
@submit="handleBindExecutionSubmit"
/>
<PersonalItemStatusActionDialog
v-model:visible="statusActionVisible"
:action="currentStatusAction"
@submit="handleStatusActionSubmit"
/>
</div>
</template>
<style scoped lang="scss">
.personal-item-row-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.personal-item-row-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.personal-item-row-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
:deep(.personal-item-title-link) {
max-width: 100%;
padding: 0;
vertical-align: baseline;
}
:deep(.personal-item-title-link > span) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { fetchGetPersonalItemExecutionOptions } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemBindExecutionDialog' });
interface Props {
selectedCount: number;
submitLoading?: boolean;
}
interface Emits {
(e: 'submit', payload: { executionId: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
submitLoading: false
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const loading = ref(false);
const executionOptions = ref<Api.PersonalItem.PersonalItemExecutionOption[]>([]);
const model = reactive({
executionId: ''
});
const rules = computed(
() =>
({
executionId: [createRequiredRule('请选择执行')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function getExecutionOptionLabel(option: Api.PersonalItem.PersonalItemExecutionOption) {
if (option.projectName?.trim()) {
return `${option.projectName} / ${option.executionName}`;
}
return option.executionName;
}
async function loadExecutionOptions() {
loading.value = true;
const { error, data } = await fetchGetPersonalItemExecutionOptions();
loading.value = false;
if (error || !data) {
executionOptions.value = [];
return;
}
executionOptions.value = data.map(item => ({ ...item }));
}
async function initDialog() {
model.executionId = '';
await loadExecutionOptions();
formRef.value?.clearValidate();
}
async function handleConfirm() {
await validate();
emit('submit', {
executionId: model.executionId
});
}
watch(
() => visible.value,
value => {
if (value) {
initDialog();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="批量关联执行"
preset="sm"
:loading="loading"
:confirm-loading="props.submitLoading"
@confirm="handleConfirm"
>
<ElAlert
:title="`已选中 ${props.selectedCount} 条个人事项,关联成功后这些事项会从当前列表移除。`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="执行" prop="executionId">
<ElSelect
v-model="model.executionId"
clearable
filterable
placeholder="请选择执行"
class="w-full"
:loading="loading"
>
<ElOption
v-for="option in executionOptions"
:key="option.executionId"
:label="getExecutionOptionLabel(option)"
:value="option.executionId"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { fetchGetPersonalItemDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import PersonalItemWorklogPanel from './personal-item-worklog-panel.vue';
defineOptions({ name: 'PersonalItemDetailDialog' });
type TabName = 'worklog';
interface Props {
rowData?: Api.PersonalItem.PersonalItem | null;
defaultTab?: TabName;
}
const props = withDefaults(defineProps<Props>(), {
defaultTab: 'worklog'
});
const emit = defineEmits<{
changed: [item: Api.PersonalItem.PersonalItem];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const activeTab = ref<TabName>('worklog');
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
function syncDetailFromPageRow() {
detailData.value = props.rowData ?? null;
}
async function refreshDetail() {
if (!detailData.value?.id) {
return;
}
const { error, data } = await fetchGetPersonalItemDetail(detailData.value.id);
if (!error && data) {
detailData.value = data;
}
}
async function handleWorklogChanged() {
await refreshDetail();
if (detailData.value) {
emit('changed', detailData.value);
}
}
watch(
() => visible.value,
value => {
if (value) {
activeTab.value = props.defaultTab;
syncDetailFromPageRow();
}
}
);
watch(
() => props.rowData,
() => {
if (visible.value) {
syncDetailFromPageRow();
}
}
);
watch(
() => props.defaultTab,
value => {
if (visible.value) {
activeTab.value = value;
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="工作日志"
width="1100px"
max-body-height="78vh"
:show-footer="false"
:scrollbar="false"
>
<ElTabs v-model="activeTab" class="personal-item-detail-dialog__tabs">
<ElTabPane label="工作日志" name="worklog" lazy>
<PersonalItemWorklogPanel
v-if="detailData"
:item="detailData"
:active="activeTab === 'worklog' && visible"
@changed="handleWorklogChanged"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.personal-item-detail-dialog__tabs {
--el-tabs-header-height: 40px;
}
.personal-item-detail-dialog__tabs :deep(.el-tabs__content),
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
min-height: 640px;
}
</style>

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchCreatePersonalItem, fetchGetPersonalItemDetail, fetchUpdatePersonalItem } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import { isEmptyRichText } from './personal-item-shared';
defineOptions({ name: 'PersonalItemOperateDialog' });
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface Props {
operateType: PersonalItemOperateType;
rowData?: Api.PersonalItem.PersonalItem | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || 'current-user');
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
const isEdit = computed(() => props.operateType === 'edit');
const isView = computed(() => props.operateType === 'view');
const detailLoading = ref(false);
const submitting = ref(false);
interface Model {
taskTitle: string;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<Model>(createDefaultModel());
const title = computed(() => {
if (isView.value) {
return '个人事项详情';
}
return isEdit.value ? '编辑个人事项' : '新增个人事项';
});
function createDefaultModel(): Model {
return {
taskTitle: '',
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
attachments: []
};
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
const rules = computed(
() =>
({
taskTitle: [
createRequiredRule('请输入事项标题'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入事项标题'));
return;
}
callback();
},
trigger: 'blur'
}
],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function initModel() {
detailLoading.value = true;
Object.assign(model, createDefaultModel());
if ((isEdit.value || isView.value) && props.rowData) {
const { error, data } = await fetchGetPersonalItemDetail(props.rowData.id);
if (!error && data) {
model.taskTitle = data.taskTitle;
model.plannedStartDate = data.plannedStartDate;
model.plannedEndDate = data.plannedEndDate;
model.taskDesc = data.taskDesc;
model.attachments = data.attachments ? [...data.attachments] : [];
}
}
detailLoading.value = false;
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (isView.value) {
visible.value = false;
return;
}
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const payload: Api.PersonalItem.SavePersonalItemParams = {
taskTitle: model.taskTitle.trim(),
ownerId: currentUserId.value,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
attachments: [...model.attachments]
};
submitting.value = true;
const result =
isEdit.value && props.rowData
? await fetchUpdatePersonalItem({ id: props.rowData.id, ...payload })
: await fetchCreatePersonalItem(payload);
submitting.value = false;
if (result.error) {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
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"
width="1100px"
:loading="detailLoading"
:confirm-loading="submitting"
:show-footer="!isView"
max-body-height="78vh"
@confirm="handleSubmit"
>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="personal-item-operate-dialog__form"
>
<div class="personal-item-operate-dialog__grid">
<div ref="leftColRef" class="personal-item-operate-dialog__col-left">
<BusinessFormSection title="事项信息">
<ElFormItem label="事项标题" prop="taskTitle">
<ElInput
v-model="model.taskTitle"
:clearable="!isView"
:disabled="isView"
maxlength="300"
placeholder="请输入事项标题"
/>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划开始日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划结束日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="personal-item-operate-dialog__col-right">
<BusinessFormSection title="事项说明">
<ElFormItem class="personal-item-operate-dialog__desc-item" prop="taskDesc">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.taskDesc"
:height="editorHeight"
:disabled="isView"
upload-directory="personal-item"
placeholder="请输入事项说明"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="personal-item-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="personal-item"
:disabled="isView"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.personal-item-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.personal-item-operate-dialog__col-left,
.personal-item-operate-dialog__col-right {
min-width: 0;
}
.personal-item-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.personal-item-operate-dialog__desc-item,
.personal-item-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.personal-item-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
:deep(.personal-item-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { fetchGetObjectStatusModelPage } from '@/service/api';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { personalItemStatusOptions } from './personal-item-shared';
defineOptions({ name: 'PersonalItemSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.PersonalItem.PersonalItemSearchParams>('model', {
required: true
});
const searchModel = reactive<{
keyword: string;
statusCode?: Api.PersonalItem.PersonalItemStatusCode;
}>({
keyword: '',
statusCode: undefined
});
let syncingFromSource = false;
const statusOptions = ref<Array<{ label: string; value: string }>>([...personalItemStatusOptions]);
watch(
() => [model.value.keyword, model.value.statusCode] as const,
([keyword, statusCode]) => {
syncingFromSource = true;
searchModel.keyword = keyword ?? '';
searchModel.statusCode = statusCode;
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() => [searchModel.keyword, searchModel.statusCode] as const,
([keyword, statusCode]) => {
if (syncingFromSource) {
return;
}
model.value.keyword = keyword.trim() || undefined;
model.value.statusCode = statusCode;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入标题或说明'
},
{
key: 'statusCode',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: statusOptions.value
}
]);
async function loadStatusOptions() {
const { error, data } = await fetchGetObjectStatusModelPage({
pageNo: 1,
pageSize: 100,
objectType: 'task',
status: 0,
initialFlag: undefined,
terminalFlag: undefined,
keyword: undefined
});
if (error || !data?.list?.length) {
statusOptions.value = [...personalItemStatusOptions];
return;
}
statusOptions.value = data.list
.slice()
.sort((left, right) => left.sort - right.sort)
.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
onMounted(() => {
loadStatusOptions();
});
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="3" @reset="handleReset" @search="handleSearch" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,105 @@
import dayjs from 'dayjs';
import { getPersonalItemStatusTagType } from '@/constants/status-tag';
export const personalItemStatusOptions = [
{ label: '待处理', value: 'pending' as const },
{ label: '进行中', value: 'active' as const },
{ label: '已完成', value: 'completed' as const }
];
const personalItemStatusLabelMap: Record<Api.PersonalItem.PersonalItemStatusCode, string> = {
pending: '待开始',
active: '进行中',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消'
};
export function getPersonalItemStatusLabel(statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined) {
if (!statusCode) {
return '--';
}
return personalItemStatusLabelMap[statusCode] || '--';
}
export function resolvePersonalItemStatusTagType(
statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined
) {
return getPersonalItemStatusTagType(statusCode);
}
export function formatPersonalItemDate(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD');
}
export function formatPersonalItemDateTime(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD HH:mm:ss');
}
export function formatPersonalItemProgress(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '0%';
}
const normalized = Math.round(Math.min(100, Math.max(0, value)) * 100) / 100;
return `${normalized}%`;
}
export function formatPersonalItemName(value: string | null | undefined) {
return value?.trim() || '--';
}
export function formatPersonalItemOwnerName(
item: Pick<Api.PersonalItem.PersonalItem, 'ownerNickname' | 'ownerName' | 'ownerId'>
) {
return item.ownerNickname?.trim() || item.ownerName?.trim() || item.ownerId || '--';
}
export function formatPersonalItemDateRange(start: string | null | undefined, end: string | null | undefined) {
const startText = formatPersonalItemDate(start);
const endText = formatPersonalItemDate(end);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText} ~ ${endText}`;
}
export function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}

View File

@@ -0,0 +1,73 @@
<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: 'PersonalItemStatusActionDialog' });
interface Props {
action: Api.PersonalItem.PersonalItemLifecycleAction | null;
}
interface Emits {
(e: 'submit', reason: string | null): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive({
reason: ''
});
const rules = computed(
() =>
({
reason: props.action?.needReason ? [createRequiredRule('请输入动作原因')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
emit('submit', model.reason.trim() || null);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.reason = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="action?.actionName || '状态变更'" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="动作原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="action?.needReason ? '请输入动作原因' : '可选填写动作原因'"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,409 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemWorklogFormDialog' });
type Mode = 'create' | 'edit' | 'view';
type Granularity = 'day' | 'week';
interface Props {
mode: Mode;
rowData: Api.PersonalItem.PersonalItemWorklog | null;
itemStatusCode: Api.PersonalItem.PersonalItemStatusCode;
defaultProgressRate?: number;
confirmLoading?: boolean;
}
interface Emits {
(e: 'submit', payload: Api.PersonalItem.SavePersonalItemWorklogParams): void;
}
const props = withDefaults(defineProps<Props>(), {
defaultProgressRate: 0,
confirmLoading: false
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const isView = computed(() => props.mode === 'view');
const isProgressReadonly = computed(() => isView.value || props.itemStatusCode === 'completed');
interface FormModel {
granularity: Granularity;
workDate: string | null;
weekDate: Date | null;
durationHours: number | null;
progressRate: number;
difficulty: string;
workContent: string | null;
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<FormModel>({
granularity: 'day',
workDate: null,
weekDate: null,
durationHours: null,
progressRate: 0,
difficulty: '2',
workContent: null,
attachments: []
});
const granularityOptions = [
{ label: '按天', value: 'day' as const },
{ label: '按周', value: 'week' as const }
];
const dialogTitle = computed(() => {
if (props.mode === 'create') return '填写工作日志';
if (props.mode === 'view') return '查看工作日志';
return '编辑工作日志';
});
const dateFieldLabel = computed(() => (model.granularity === 'day' ? '工作日期' : '工作周次'));
const workDateShortcuts = [
{ text: '今天', value: () => new Date() },
{ text: '昨天', value: () => dayjs().subtract(1, 'day').toDate() },
{ text: '前天', value: () => dayjs().subtract(2, 'day').toDate() }
];
const weekDateShortcuts = [
{ text: '本周', value: () => dayjs().startOf('isoWeek').toDate() },
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
];
const weekRangeTooltip = computed(() => {
if (!model.weekDate) return '';
const start = dayjs(model.weekDate);
if (!start.isValid()) return '';
return `${start.format('YYYY-MM-DD')} ~ ${start.add(6, 'day').format('YYYY-MM-DD')}`;
});
const rules = computed(
() =>
({
granularity: [createRequiredRule('请选择填报粒度')],
workDate: [
{
required: true,
validator: (_rule, value: string | null, callback) => {
if (model.granularity !== 'day') {
callback();
return;
}
if (!value) {
callback(new Error('请选择工作日期'));
return;
}
callback();
},
trigger: 'change'
}
],
weekDate: [
{
required: true,
validator: (_rule, value: Date | null, callback) => {
if (model.granularity !== 'week') {
callback();
return;
}
if (!value) {
callback(new Error('请选择工作周次'));
return;
}
callback();
},
trigger: 'change'
}
],
durationHours: [
{
required: true,
validator: (_rule, value: number | null, callback) => {
if (value === null || value === undefined) {
callback(new Error('请输入工时'));
return;
}
if (value <= 0) {
callback(new Error('工时必须大于 0'));
return;
}
if (Math.round(value * 10) % 5 !== 0) {
callback(new Error('工时必须是 0.5 小时的整数倍'));
return;
}
callback();
},
trigger: 'change'
}
],
progressRate: [
{
required: true,
validator: (_rule, value: number, callback) => {
if (value < 0 || value > 100) {
callback(new Error('进度需在 0 到 100 之间'));
return;
}
callback();
},
trigger: 'change'
}
],
difficulty: [createRequiredRule('请选择完成难度')],
workContent: [
{
required: true,
validator: (_rule, value: string | null, callback) => {
if (!value || !value.trim()) {
callback(new Error('请输入工作内容'));
return;
}
callback();
},
trigger: 'blur'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
function detectGranularityFromRow(row: Api.PersonalItem.PersonalItemWorklog): Granularity {
if (row.startDate === row.endDate) {
return 'day';
}
const start = dayjs(row.startDate);
const end = dayjs(row.endDate);
if (start.isoWeekday() === 1 && end.isoWeekday() === 7 && end.diff(start, 'day') === 6) {
return 'week';
}
return 'day';
}
function getStartEndFromModel(): { startDate: string; endDate: string } {
if (model.granularity === 'day') {
return {
startDate: model.workDate!,
endDate: model.workDate!
};
}
const weekStart = dayjs(model.weekDate!).startOf('isoWeek');
return {
startDate: weekStart.format('YYYY-MM-DD'),
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
};
}
watch(
() => model.granularity,
() => {
formRef.value?.clearValidate();
}
);
async function handleConfirm() {
if (isView.value) {
visible.value = false;
return;
}
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const { startDate, endDate } = getStartEndFromModel();
const payload: Api.PersonalItem.SavePersonalItemWorklogParams = {
startDate,
endDate,
durationHours: Number(model.durationHours!.toFixed(1)),
progressRate: Number(model.progressRate.toFixed(2)),
difficulty: model.difficulty,
workContent: model.workContent?.trim() || null,
attachments: [...model.attachments]
};
emit('submit', payload);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
const row = props.rowData;
if (row) {
const granularity = detectGranularityFromRow(row);
model.granularity = granularity;
model.workDate = granularity === 'day' ? row.startDate : null;
model.weekDate = granularity === 'week' ? dayjs(row.startDate).toDate() : null;
model.durationHours = row.durationHours;
model.progressRate = row.progressRate;
model.difficulty = row.difficulty || '2';
model.workContent = row.workContent || null;
model.attachments = row.attachments ? [...row.attachments] : [];
} else {
model.granularity = 'day';
model.workDate = dayjs().format('YYYY-MM-DD');
model.weekDate = null;
model.durationHours = null;
model.progressRate = props.defaultProgressRate;
model.difficulty = '2';
model.workContent = null;
model.attachments = [];
}
await nextTick();
attachmentUploaderRef.value?.initSession();
formRef.value?.clearValidate();
}
);
defineExpose({
async commit() {
await attachmentUploaderRef.value?.commit();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="md"
:confirm-loading="props.confirmLoading"
@confirm="handleConfirm"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="填报粒度" prop="granularity">
<ElSegmented v-model="model.granularity" :options="granularityOptions" :disabled="isView" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem :label="dateFieldLabel" :prop="model.granularity === 'day' ? 'workDate' : 'weekDate'">
<ElDatePicker
v-if="model.granularity === 'day'"
v-model="model.workDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择工作日期"
:shortcuts="isView ? undefined : workDateShortcuts"
:disabled="isView"
class="personal-item-worklog-form-dialog__date-picker"
/>
<ElTooltip v-else :content="weekRangeTooltip" :disabled="!weekRangeTooltip" placement="top">
<span class="personal-item-worklog-form-dialog__week-wrapper">
<ElDatePicker
v-model="model.weekDate"
type="week"
format="YYYY[年第]ww[周]"
placeholder="选择工作周次"
:shortcuts="isView ? undefined : weekDateShortcuts"
:disabled="isView"
class="personal-item-worklog-form-dialog__date-picker"
/>
</span>
</ElTooltip>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="工时(小时)" prop="durationHours">
<ElInputNumber
v-model="model.durationHours"
:min="0.5"
:step="0.5"
:precision="1"
:disabled="isView"
controls-position="right"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="进度(%" prop="progressRate">
<ElInputNumber
v-model="model.progressRate"
:min="0"
:max="100"
:step="1"
:precision="2"
:disabled="isProgressReadonly"
controls-position="right"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="完成难度" prop="difficulty">
<DictSelect
v-model="model.difficulty"
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
:disabled="isView"
:clearable="false"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="工作内容" prop="workContent">
<ElInput
v-model="model.workContent"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:maxlength="isView ? undefined : 2000"
:show-word-limit="!isView"
:disabled="isView"
placeholder="简述本次填报的工作内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="附件">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
:disabled="isView"
directory="personal-item-worklog"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<template v-if="isView" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.personal-item-worklog-form-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
.personal-item-worklog-form-dialog__week-wrapper {
display: block;
width: 100%;
}
</style>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElPopconfirm, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
import { Plus } from '@element-plus/icons-vue';
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
import {
fetchCreatePersonalItemWorklog,
fetchDeletePersonalItemWorklog,
fetchGetPersonalItemWorklogPage,
fetchUpdatePersonalItemWorklog
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import {
formatPersonalItemDate,
formatPersonalItemOwnerName,
formatPersonalItemProgress,
getPersonalItemStatusLabel,
resolvePersonalItemStatusTagType
} from './personal-item-shared';
import PersonalItemWorklogFormDialog from './personal-item-worklog-form-dialog.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiPaperclip from '~icons/mdi/paperclip';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'PersonalItemWorklogPanel' });
type WorklogGranularity = 'day' | 'week';
interface Props {
item: Api.PersonalItem.PersonalItem;
active?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
active: true
});
const emit = defineEmits<{
changed: [];
}>();
const authStore = useAuthStore();
const { getLabel: getDifficultyLabel } = useDict(RDMS_WORKLOG_DIFFICULTY_DICT_CODE);
const currentUserId = computed(() => authStore.userInfo.userId || '');
const currentUserName = computed(
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
);
const PAGE_SIZE = 10;
const TABLE_HEIGHT = 390;
const pageNo = ref(1);
const total = ref(0);
const loading = ref(false);
const records = ref<Api.PersonalItem.PersonalItemWorklog[]>([]);
const formVisible = ref(false);
const formMode = ref<'create' | 'edit' | 'view'>('create');
const submitting = ref(false);
const editingWorklog = ref<Api.PersonalItem.PersonalItemWorklog | null>(null);
const worklogFormDialogRef = ref<InstanceType<typeof PersonalItemWorklogFormDialog> | null>(null);
const totalHours = computed(() => {
if (typeof props.item.totalSpentHours === 'number' && Number.isFinite(props.item.totalSpentHours)) {
return props.item.totalSpentHours;
}
return records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0);
});
const totalHoursText = computed(() => `${totalHours.value.toFixed(1)}h`);
const ownerName = computed(() => {
const displayName = formatPersonalItemOwnerName(props.item);
if (displayName !== props.item.ownerId) {
return displayName;
}
return props.item.ownerId === currentUserId.value && currentUserName.value ? currentUserName.value : displayName;
});
const statusName = computed(() => getPersonalItemStatusLabel(props.item.statusCode));
const progressText = computed(() => formatPersonalItemProgress(props.item.progressRate));
const plannedStartText = computed(() => formatPersonalItemDate(props.item.plannedStartDate));
const plannedEndText = computed(() => formatPersonalItemDate(props.item.plannedEndDate));
const actualStartText = computed(() => formatPersonalItemDate(props.item.actualStartDate));
const actualEndText = computed(() => formatPersonalItemDate(props.item.actualEndDate));
const list = computed(() => records.value);
const canCreate = computed(() =>
Boolean(
props.item.id &&
(props.item.statusCode === 'pending' ||
props.item.statusCode === 'active' ||
props.item.statusCode === 'completed')
)
);
const isWorklogMutableStatus = computed(
() => props.item.statusCode === 'active' || props.item.statusCode === 'completed'
);
function getRowIndex(index: number) {
return (pageNo.value - 1) * PAGE_SIZE + index + 1;
}
function formatHours(hours: number | null | undefined) {
if (typeof hours !== 'number' || !Number.isFinite(hours)) {
return '0h';
}
return `${hours.toFixed(1)}h`;
}
function formatWorklogPeriod(startDate: string | null | undefined, endDate: string | null | undefined) {
if (!startDate || !endDate) {
return {
granularity: null as WorklogGranularity | null,
display: '--',
tooltip: null as string | null
};
}
const startKey = formatPersonalItemDate(startDate);
const endKey = formatPersonalItemDate(endDate);
if (startKey === endKey) {
const current = dayjs(startDate);
const weekSuffix = current.isValid() ? `(第${current.isoWeek()}周)` : '';
return {
granularity: 'day' as const,
display: `${startKey}${weekSuffix}`,
tooltip: null
};
}
const start = dayjs(startDate);
return {
granularity: 'week' as const,
display: start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : `${startKey} ~ ${endKey}`,
tooltip: `${startKey} ~ ${endKey}`
};
}
function getWorklogGranularityName(granularity: WorklogGranularity | null) {
if (granularity === 'day') {
return '日';
}
if (granularity === 'week') {
return '周';
}
return '--';
}
function canEditRow(row: Api.PersonalItem.PersonalItemWorklog) {
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
}
function canDeleteRow(row: Api.PersonalItem.PersonalItemWorklog) {
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
}
async function loadRecords() {
if (!props.item.id || !props.active) {
return;
}
loading.value = true;
const { error, data } = await fetchGetPersonalItemWorklogPage(props.item.id, {
pageNo: pageNo.value,
pageSize: PAGE_SIZE
});
loading.value = false;
if (error || !data) {
records.value = [];
total.value = 0;
return;
}
records.value = data.list;
total.value = data.total;
}
function handlePageChange(page: number) {
pageNo.value = page;
loadRecords();
}
function openCreate() {
formMode.value = 'create';
editingWorklog.value = null;
formVisible.value = true;
}
function openView(row: Api.PersonalItem.PersonalItemWorklog) {
formMode.value = 'view';
editingWorklog.value = row;
formVisible.value = true;
}
function openEdit(row: Api.PersonalItem.PersonalItemWorklog) {
formMode.value = 'edit';
editingWorklog.value = row;
formVisible.value = true;
}
async function handleDelete(row: Api.PersonalItem.PersonalItemWorklog) {
const shouldStepBack = records.value.length === 1 && pageNo.value > 1;
const { error } = await fetchDeletePersonalItemWorklog(props.item.id, row.id);
if (error) {
return;
}
if (shouldStepBack) {
pageNo.value -= 1;
}
window.$message?.success('工作日志删除成功');
await loadRecords();
emit('changed');
}
async function handleSubmit(payload: Api.PersonalItem.SavePersonalItemWorklogParams) {
submitting.value = true;
try {
const result =
formMode.value === 'edit' && editingWorklog.value
? await fetchUpdatePersonalItemWorklog(props.item.id, {
worklogId: editingWorklog.value.id,
data: payload
})
: await fetchCreatePersonalItemWorklog(props.item.id, payload);
if (result.error) {
return;
}
await worklogFormDialogRef.value?.commit();
window.$message?.success(formMode.value === 'edit' ? '工作日志修改成功' : '工作日志新增成功');
formVisible.value = false;
await loadRecords();
emit('changed');
} finally {
submitting.value = false;
}
}
watch(total, value => {
const maxPage = Math.max(1, Math.ceil(value / PAGE_SIZE));
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
}
});
watch(
[() => props.item.id, () => props.active],
([itemId, active]) => {
if (!itemId) {
records.value = [];
return;
}
pageNo.value = 1;
if (active) {
loadRecords();
}
},
{ immediate: true }
);
</script>
<template>
<div class="personal-item-worklog-panel">
<div class="personal-item-worklog-panel__cards">
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">负责人</span>
<span class="personal-item-worklog-panel__card-value" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">当前状态</span>
<ElTag
:type="resolvePersonalItemStatusTagType(props.item.statusCode)"
size="small"
effect="light"
class="personal-item-worklog-panel__card-tag"
>
{{ statusName }}
</ElTag>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">计划开始</span>
<span class="personal-item-worklog-panel__card-value">{{ plannedStartText }}</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">计划结束</span>
<span class="personal-item-worklog-panel__card-value">{{ plannedEndText }}</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">当前进度</span>
<span class="personal-item-worklog-panel__card-value">{{ progressText }}</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">累计工时</span>
<span class="personal-item-worklog-panel__card-value personal-item-worklog-panel__card-value--accent">
{{ totalHoursText }}
</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">实际开始</span>
<span class="personal-item-worklog-panel__card-value">{{ actualStartText }}</span>
</div>
<div class="personal-item-worklog-panel__card">
<span class="personal-item-worklog-panel__card-label">实际结束</span>
<span class="personal-item-worklog-panel__card-value">{{ actualEndText }}</span>
</div>
</div>
<header v-if="canCreate" class="personal-item-worklog-panel__header">
<ElButton type="primary" size="small" :icon="Plus" @click="openCreate">填报</ElButton>
</header>
<ElTable
v-loading="loading"
:data="list"
:height="TABLE_HEIGHT"
border
empty-text="暂无工作日志"
class="personal-item-worklog-panel__table"
>
<ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" />
<ElTableColumn label="粒度" width="70" align="center">
<template #default="{ row }">
<ElTag
:type="formatWorklogPeriod(row.startDate, row.endDate).granularity === 'week' ? 'warning' : 'info'"
size="small"
effect="plain"
>
{{ getWorklogGranularityName(formatWorklogPeriod(row.startDate, row.endDate).granularity) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="日期" width="180" align="center">
<template #default="{ row }">
<ElTooltip
v-if="formatWorklogPeriod(row.startDate, row.endDate).tooltip"
:content="formatWorklogPeriod(row.startDate, row.endDate).tooltip ?? ''"
placement="top"
>
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
</ElTooltip>
<span v-else>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="工作内容" min-width="320">
<template #default="{ row }">
<ElPopover
v-if="row.workContent || (row.attachments && row.attachments.length)"
trigger="hover"
placement="top"
:width="360"
:show-after="200"
popper-class="personal-item-worklog-panel__content-popover"
>
<template #reference>
<span class="personal-item-worklog-panel__content-cell">
{{ row.workContent || `附件 ${row.attachments?.length ?? 0}` }}
</span>
</template>
<div class="personal-item-worklog-panel__content-card">
<div class="personal-item-worklog-panel__content-card-header">
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
<span class="personal-item-worklog-panel__content-card-meta">
{{ formatHours(row.durationHours) }} / {{ formatPersonalItemProgress(row.progressRate) }} /
{{ getDifficultyLabel(row.difficulty, { fallback: '--' }) }}
</span>
</div>
<div v-if="row.workContent" class="personal-item-worklog-panel__content-card-body">
{{ row.workContent }}
</div>
<div class="personal-item-worklog-panel__content-card-attachments">
<div class="personal-item-worklog-panel__content-card-section-title">
<ElIcon><IconMdiPaperclip /></ElIcon>
<span v-if="row.attachments && row.attachments.length">附件{{ row.attachments.length }}</span>
<span v-else class="personal-item-worklog-panel__content-card-attachment-empty">无附件</span>
</div>
<div
v-if="row.attachments && row.attachments.length"
class="personal-item-worklog-panel__content-card-attachments-scroll"
>
<BusinessAttachmentUploader :model-value="row.attachments" disabled flat />
</div>
</div>
</div>
</ElPopover>
<span v-else class="personal-item-worklog-panel__content-cell-empty">--</span>
</template>
</ElTableColumn>
<ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }">
<span class="personal-item-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="100" align="center">
<template #default="{ row }">
<span class="personal-item-worklog-panel__progress">{{ formatPersonalItemProgress(row.progressRate) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<div class="personal-item-worklog-panel__actions" @click.stop>
<ElTooltip content="查看">
<ElButton link type="primary" class="personal-item-worklog-panel__action-btn" @click="openView(row)">
<IconMdiEyeOutline class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip :content="canEditRow(row) ? '编辑' : '仅可编辑本人填报'">
<span class="inline-flex">
<ElButton
link
type="primary"
class="personal-item-worklog-panel__action-btn"
:disabled="!canEditRow(row)"
@click="openEdit(row)"
>
<IconMdiPencilOutline class="text-15px" />
</ElButton>
</span>
</ElTooltip>
<ElPopconfirm
v-if="canDeleteRow(row)"
title="确认删除该条工作日志?"
confirm-button-text="删除"
cancel-button-text="取消"
confirm-button-type="danger"
@confirm="handleDelete(row)"
>
<template #reference>
<span class="inline-flex">
<ElTooltip content="删除">
<ElButton link type="danger" class="personal-item-worklog-panel__action-btn">
<IconMdiDeleteOutline class="text-15px" />
</ElButton>
</ElTooltip>
</span>
</template>
</ElPopconfirm>
<ElTooltip v-else content="仅可删除本人填报">
<span class="inline-flex">
<ElButton link type="danger" class="personal-item-worklog-panel__action-btn" disabled>
<IconMdiDeleteOutline class="text-15px" />
</ElButton>
</span>
</ElTooltip>
</div>
</template>
</ElTableColumn>
</ElTable>
<div class="personal-item-worklog-panel__pagination">
<ElPagination
v-if="total > 0"
small
background
layout="total, prev, pager, next"
:current-page="pageNo"
:page-size="PAGE_SIZE"
:total="total"
@current-change="handlePageChange"
/>
</div>
<PersonalItemWorklogFormDialog
ref="worklogFormDialogRef"
v-model:visible="formVisible"
:mode="formMode"
:row-data="editingWorklog"
:item-status-code="props.item.statusCode"
:default-progress-rate="props.item.progressRate"
:confirm-loading="submitting"
@submit="handleSubmit"
/>
</div>
</template>
<style scoped lang="scss">
.personal-item-worklog-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.personal-item-worklog-panel__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.personal-item-worklog-panel__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.personal-item-worklog-panel__card-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.2;
}
.personal-item-worklog-panel__card-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.personal-item-worklog-panel__card-value--accent {
color: var(--el-color-primary);
}
.personal-item-worklog-panel__card-tag {
align-self: flex-start;
}
.personal-item-worklog-panel__header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.personal-item-worklog-panel__duration {
color: var(--el-color-primary);
font-weight: 500;
}
.personal-item-worklog-panel__progress {
color: var(--el-color-primary);
font-weight: 500;
}
.personal-item-worklog-panel__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.personal-item-worklog-panel__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.personal-item-worklog-panel__action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
.personal-item-worklog-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
.personal-item-worklog-panel__content-cell {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
cursor: default;
}
.personal-item-worklog-panel__content-cell-empty {
color: var(--el-text-color-placeholder);
}
.personal-item-worklog-panel__content-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 2px;
}
.personal-item-worklog-panel__content-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.personal-item-worklog-panel__content-card-meta {
color: var(--el-color-primary);
font-weight: 500;
}
.personal-item-worklog-panel__content-card-body {
font-size: 13px;
line-height: 1.65;
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow-y: auto;
}
.personal-item-worklog-panel__content-card-attachments {
display: flex;
flex-direction: column;
gap: 6px;
}
.personal-item-worklog-panel__content-card-attachments-scroll {
max-height: 144px;
overflow-y: auto;
padding-right: 4px;
}
.personal-item-worklog-panel__content-card-section-title {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.personal-item-worklog-panel__content-card-attachment-empty {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { userGenderRecord } from '@/constants/business';
import { fetchGetMyProfileDetail, fetchUpdateMyProfile } from '@/service/api';
import { buildFileProxyUrl, uploadFile } from '@/service/api/file';
import { fetchGetMyProfileDetail, fetchUpdateMyAvatar } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import ProfileInfoDialog from './modules/profile-info-dialog.vue';
import ProfilePasswordDialog from './modules/profile-password-dialog.vue';
import { buildProfileUpdatePayload, formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
import { formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
defineOptions({ name: 'MyProfile' });
@@ -22,6 +21,8 @@ const profileInfoVisible = ref(false);
const passwordVisible = ref(false);
const avatarInputRef = ref<HTMLInputElement | null>(null);
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const descriptionColumns = computed(() => (appStore.isMobile ? 1 : 2));
const displayName = computed(() => profile.value?.nickname?.trim() || profile.value?.username || '--');
const displayUsername = computed(() => profile.value?.username?.trim() || '--');
@@ -41,6 +42,7 @@ const genderText = computed(() => {
return $t(userGenderRecord[value]);
});
const roleLabels = computed(() => {
const roles = profile.value?.roles ?? [];
@@ -57,16 +59,6 @@ function getAvatarText() {
return name === '--' ? 'CN' : name.slice(0, 1).toUpperCase();
}
function getEditableProfileValues(current: Api.Auth.MyProfileDetail): Api.Auth.UpdateMyProfileParams {
return {
nickname: current.nickname ?? '',
email: current.email ?? '',
mobile: current.mobile ?? '',
sex: current.sex ?? 1,
avatar: current.avatar ?? ''
};
}
async function loadProfile() {
const userId = authStore.userInfo.userId;
@@ -75,9 +67,7 @@ async function loadProfile() {
return;
}
const { data, error } = await fetchGetMyProfileDetail({
userId
});
const { data, error } = await fetchGetMyProfileDetail({ userId });
if (!error) {
profile.value = data;
@@ -115,21 +105,14 @@ async function handleAvatarChange(event: Event) {
return;
}
avatarSubmitting.value = true;
const uploadResult = await uploadFile(file, 'avatar');
if (uploadResult.error) {
avatarSubmitting.value = false;
if (file.size > MAX_AVATAR_SIZE) {
window.$message?.error('头像图片大小不能超过 5MB');
return;
}
const avatar = buildFileProxyUrl(uploadResult.data.configId, uploadResult.data.path);
const payload = buildProfileUpdatePayload({
...getEditableProfileValues(profile.value),
avatar
});
const updateResult = await fetchUpdateMyProfile(payload);
avatarSubmitting.value = true;
const updateResult = await fetchUpdateMyAvatar(file);
avatarSubmitting.value = false;
@@ -212,7 +195,7 @@ onActivated(() => {
<ElDescriptions :column="descriptionColumns" border>
<ElDescriptionsItem label="用户名">{{ displayUsername }}</ElDescriptionsItem>
<ElDescriptionsItem label="称">{{ displayName }}</ElDescriptionsItem>
<ElDescriptionsItem label="称">{{ displayName }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ mobileText }}</ElDescriptionsItem>
<ElDescriptionsItem label="邮箱">{{ emailText }}</ElDescriptionsItem>
<ElDescriptionsItem label="性别">{{ genderText }}</ElDescriptionsItem>

View File

@@ -321,7 +321,7 @@ onMounted(async () => {
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
</ElButton>
</template>
</TableHeaderOperation>