4 Commits

Author SHA1 Message Date
b72ad00912 fix(error-message): 删除用户可见错误文案规范HTML文档
- 移除了完整的用户可见错误文案规范HTML文件
2026-06-04 21:07:44 +08:00
7cc29e0a35 fix(projects): 针对技术负债去优化代码 2026-06-04 21:06:05 +08:00
39458386ae feat(projects): 工作台部分组件调成真实数据 2026-06-04 11:26:51 +08:00
dk
acef4418d8 fix(加班申请): 使用后端专门返回状态的接口,代替使用字典。
fix(status-tag.ts):把产品需求、项目需求的状态颜色定义收敛到此处。
2026-06-04 10:49:34 +08:00
47 changed files with 1141 additions and 1233 deletions

View File

@@ -64,6 +64,7 @@
"dompurify": "3.2.6", "dompurify": "3.2.6",
"echarts": "6.0.0", "echarts": "6.0.0",
"element-plus": "^2.11.1", "element-plus": "^2.11.1",
"grid-layout-plus": "^1.1.1",
"jsbarcode": "3.12.1", "jsbarcode": "3.12.1",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"json5": "2.2.3", "json5": "2.2.3",

49
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
element-plus: element-plus:
specifier: ^2.11.1 specifier: ^2.11.1
version: 2.13.6(typescript@5.8.3)(vue@3.5.20(typescript@5.8.3)) version: 2.13.6(typescript@5.8.3)(vue@3.5.20(typescript@5.8.3))
grid-layout-plus:
specifier: ^1.1.1
version: 1.1.1(vue@3.5.20(typescript@5.8.3))
jsbarcode: jsbarcode:
specifier: 3.12.1 specifier: 3.12.1
version: 3.12.1 version: 3.12.1
@@ -882,6 +885,9 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3' vue: '>=3'
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@intlify/core-base@11.1.11': '@intlify/core-base@11.1.11':
resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==} resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@@ -921,6 +927,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@naoak/workerize-transferable@0.1.0': '@naoak/workerize-transferable@0.1.0':
resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==} resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==}
peerDependencies: peerDependencies:
@@ -1817,6 +1826,14 @@ packages:
peerDependencies: peerDependencies:
'@uppy/core': ^2.3.3 '@uppy/core': ^2.3.3
'@vexip-ui/hooks@2.9.4':
resolution: {integrity: sha512-dGUiBAeHIsnSVigGSPHcuHBVqrSGW8LV+zGohvOpBfXs8Ynn5ZcSmybIWJ3G826NsicPu9rqwcJG8uvSgG4k4Q==}
peerDependencies:
vue: ^3.2.25
'@vexip-ui/utils@2.16.4':
resolution: {integrity: sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==}
'@visactor/vchart-theme@1.12.2': '@visactor/vchart-theme@1.12.2':
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==} resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
peerDependencies: peerDependencies:
@@ -3493,6 +3510,11 @@ packages:
graphlib@2.1.8: graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
grid-layout-plus@1.1.1:
resolution: {integrity: sha512-7CWehJubrVC8Ps5QFUlnDsp0kiREvKfi3Pdjp21EyY8BNzSusqI3Utcxvu1Y9UUKe3YExvbhJzIxHK6rorbRaQ==}
peerDependencies:
vue: ^3.0.0
gzip-size@6.0.0: gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3629,6 +3651,9 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0: internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6226,6 +6251,8 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
vue: 3.5.20(typescript@5.8.3) vue: 3.5.20(typescript@5.8.3)
'@interactjs/types@1.10.27': {}
'@intlify/core-base@11.1.11': '@intlify/core-base@11.1.11':
dependencies: dependencies:
'@intlify/message-compiler': 11.1.11 '@intlify/message-compiler': 11.1.11
@@ -6273,6 +6300,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@juggle/resize-observer@3.4.0': {}
'@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.105.4))': '@naoak/workerize-transferable@0.1.0(workerize-loader@2.0.2(webpack@5.105.4))':
dependencies: dependencies:
workerize-loader: 2.0.2(webpack@5.105.4) workerize-loader: 2.0.2(webpack@5.105.4)
@@ -7082,6 +7111,15 @@ snapshots:
'@uppy/utils': 4.1.3 '@uppy/utils': 4.1.3
nanoid: 3.3.11 nanoid: 3.3.11
'@vexip-ui/hooks@2.9.4(vue@3.5.20(typescript@5.8.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@juggle/resize-observer': 3.4.0
'@vexip-ui/utils': 2.16.4
vue: 3.5.20(typescript@5.8.3)
'@vexip-ui/utils@2.16.4': {}
'@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)': '@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
dependencies: dependencies:
'@visactor/vchart': 2.0.4 '@visactor/vchart': 2.0.4
@@ -9179,6 +9217,13 @@ snapshots:
dependencies: dependencies:
lodash: 4.17.23 lodash: 4.17.23
grid-layout-plus@1.1.1(vue@3.5.20(typescript@5.8.3)):
dependencies:
'@vexip-ui/hooks': 2.9.4(vue@3.5.20(typescript@5.8.3))
'@vexip-ui/utils': 2.16.4
interactjs: 1.10.27
vue: 3.5.20(typescript@5.8.3)
gzip-size@6.0.0: gzip-size@6.0.0:
dependencies: dependencies:
duplexer: 0.1.2 duplexer: 0.1.2
@@ -9295,6 +9340,10 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0

View File

@@ -112,14 +112,6 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
*/ */
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty'; export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
/**
* 加班申请状态字典编码
*
* 对应业务字段:加班申请中的 statusCode
* 来源口径:`overtime-application-design.md` 明确状态字典为 rdms_overtime_application_status
*/
export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_application_status';
/** /**
* 加班时长快捷选项字典编码 * 加班时长快捷选项字典编码
* *

View File

@@ -14,7 +14,8 @@ export type StatusDomain =
| 'taskAssigneeMember' | 'taskAssigneeMember'
| 'project' | 'project'
| 'product' | 'product'
| 'requirement' | 'productRequirement'
| 'projectRequirement'
| 'workOrder' | 'workOrder'
| 'personalItem' | 'personalItem'
| 'overtimeApplication'; | 'overtimeApplication';
@@ -52,8 +53,31 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
project: {}, project: {},
// 产品(待补全) // 产品(待补全)
product: {}, product: {},
// 需求(待补全) // 产品需求
requirement: {}, productRequirement: {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 项目需求
projectRequirement: {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
},
// 工单(待补全) // 工单(待补全)
workOrder: {}, workOrder: {},
// 个人事项 // 个人事项
@@ -83,7 +107,3 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) { export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('personalItem', statusCode); return getStatusTagType('personalItem', statusCode);
} }
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) {
return getStatusTagType('overtimeApplication', statusCode);
}

View File

@@ -269,6 +269,19 @@ export async function fetchGetOvertimeApplicationStatusLogs(id: string) {
); );
} }
export async function fetchGetOvertimeApplicationStatusDict() {
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
...safeJsonRequestConfig,
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
data => data
);
}
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) { export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
const query = createPageQuery(params); const query = createPageQuery(params);

View File

@@ -40,6 +40,42 @@ export type ProjectExecutionResponse = Omit<
priorityName?: string | null; priorityName?: string | null;
}; };
export type MyExecutionResponse = Omit<
Api.Project.MyExecutionItem,
| 'id'
| 'projectId'
| 'projectRequirementId'
| 'priority'
| 'progressRate'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
projectRequirementId?: StringIdResponse | null;
priority?: string | number | null;
progressRate?: number | null;
plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
};
export type MyParticipatedProjectResponse = Omit<Api.Project.MyParticipatedProjectItem, 'id'> & {
id: StringIdResponse;
};
export type MyOwnedProjectMemberResponse = Omit<Api.Project.MyOwnedProjectMember, 'userId'> & {
userId: StringIdResponse;
};
export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' | 'members'> & {
id: StringIdResponse;
members?: MyOwnedProjectMemberResponse[] | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse; id: StringIdResponse;
executionId: StringIdResponse; executionId: StringIdResponse;
@@ -286,6 +322,50 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
}; };
} }
export function normalizeMyExecution(response: MyExecutionResponse): Api.Project.MyExecutionItem {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
statusName: response.statusName ?? null,
priority: normalizePriority(response.priority),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null
};
}
export function normalizeMyParticipatedProject(
response: MyParticipatedProjectResponse
): Api.Project.MyParticipatedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
statusName: response.statusName ?? null,
myRole: response.myRole ?? null
};
}
export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.Project.MyOwnedProjectItem {
return {
...response,
id: normalizeStringId(response.id),
code: response.code ?? null,
myRole: response.myRole ?? null,
plannedEndDate: response.plannedEndDate ?? null,
members: (response.members ?? []).map(member => ({
...member,
userId: normalizeStringId(member.userId),
userName: member.userName ?? null
}))
};
}
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee { export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return { return {
...response, ...response,

View File

@@ -10,6 +10,9 @@ import {
import { import {
type ExecutionAssigneeLogResponse, type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse, type ExecutionAssigneeResponse,
type MyExecutionResponse,
type MyOwnedProjectResponse,
type MyParticipatedProjectResponse,
type ProjectExecutionResponse, type ProjectExecutionResponse,
type ProjectLocalDateValue, type ProjectLocalDateValue,
type ProjectMemberResponse, type ProjectMemberResponse,
@@ -20,6 +23,9 @@ import {
getProjectLifecycleActions, getProjectLifecycleActions,
normalizeExecutionAssignee, normalizeExecutionAssignee,
normalizeExecutionAssigneeLog, normalizeExecutionAssigneeLog,
normalizeMyExecution,
normalizeMyOwnedProject,
normalizeMyParticipatedProject,
normalizeProjectExecution, normalizeProjectExecution,
normalizeProjectLocalDate, normalizeProjectLocalDate,
normalizeProjectMember, normalizeProjectMember,
@@ -365,6 +371,54 @@ export async function fetchGetProjectExecutionPage(
})); }));
} }
/** 获取工作台「我负责的执行」跨项目聚合owner 隐式取当前登录用户) */
export async function fetchGetMyExecutionPage(params?: Api.Project.MyExecutionSearchParams) {
type MyExecutionPageResponse = Api.Project.PageResult<MyExecutionResponse>;
const result = await request<MyExecutionPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/executions/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyExecutionPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyExecution)
}));
}
/** 获取工作台「我参与的项目」(成员视角,附我的角色与任务量;隐式取当前登录用户) */
export async function fetchGetMyParticipatedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyParticipatedProjectPageResponse = Api.Project.PageResult<MyParticipatedProjectResponse>;
const result = await request<MyParticipatedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/participated/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyParticipatedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyParticipatedProject)
}));
}
/** 获取工作台「我负责的项目」(项目负责人视角,附聚合统计与成员负载;隐式取当前登录用户) */
export async function fetchGetMyOwnedProjectPage(params?: Api.Project.MyProjectSearchParams) {
type MyOwnedProjectPageResponse = Api.Project.PageResult<MyOwnedProjectResponse>;
const result = await request<MyOwnedProjectPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/me/owned/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MyOwnedProjectPageResponse>, data => ({
...data,
list: data.list.map(normalizeMyOwnedProject)
}));
}
/** 获取项目执行状态看板 */ /** 获取项目执行状态看板 */
export function fetchGetProjectExecutionStatusBoard( export function fetchGetProjectExecutionStatusBoard(
projectId: string, projectId: string,

View File

@@ -74,5 +74,14 @@ declare namespace Api {
remark?: string | null; remark?: string | null;
createTime: string; createTime: string;
} }
interface OvertimeApplicationStatusDict {
statusCode: string;
statusName: string;
sort: number;
initialFlag: boolean;
terminalFlag: boolean;
allowEdit: boolean;
}
} }
} }

View File

@@ -304,6 +304,107 @@ declare namespace Api {
updateTime: string[]; updateTime: string[];
}>; }>;
/** 工作台「我负责的执行」(跨项目)查询入参 */
type MyExecutionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 预留:单状态精确过滤,不传走后端默认口径 */
statusCode: string;
/** 预留:执行名称模糊匹配 */
keyword: string;
}
>;
/** 工作台「我负责的执行」单项跨项目聚合owner 恒为当前登录用户) */
interface MyExecutionItem {
/** 执行 ID雪花 ID字符串 */
id: string;
executionName: string;
/** 所属项目 */
projectId: string;
projectName: string;
/** 执行状态编码pending / active / paused */
statusCode: string;
/** 执行状态名称 */
statusName: string | null;
/** 优先级字典 valuerdms_req_priority"0"~"3" */
priority: string;
/** 计划起止YYYY-MM-DD */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止YYYY-MM-DD */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 */
projectRequirementId: string | null;
projectRequirementName: string | null;
}
/** 工作台「我的项目」查询入参(我参与的 / 我负责的 共用) */
type MyProjectSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
/** 预留:项目名称/编码模糊关键字,后端本期不过滤 */
keyword: string;
}
>;
/** 工作台「我参与的项目」单项(成员视角,附带我的角色与任务量) */
interface MyParticipatedProjectItem {
/** 项目 ID字符串 */
id: string;
name: string;
/** 项目编码,可空 */
code: string | null;
/** 项目状态编码(如 active */
statusCode: string;
/** 项目状态名称,可空 */
statusName: string | null;
/** 项目整体进度 0-100 */
progress: number;
/** 我在该项目中的角色名(多角色拼接),可空 */
myRole: string | null;
/** 我负责的任务总数(按负责人,含已完成) */
myTaskCount: number;
/** 我负责的未完成任务数 */
myPendingTaskCount: number;
}
/** 工作台「我负责的项目」成员负载子项 */
interface MyOwnedProjectMember {
/** 成员用户 ID字符串 */
userId: string;
/** 成员姓名/昵称,可空 */
userName: string | null;
/** 该成员在本项目下进行中任务数(按负责人) */
activeTaskCount: number;
}
/** 工作台「我负责的项目」单项(项目负责人视角,附聚合统计与成员负载) */
interface MyOwnedProjectItem {
/** 项目 ID字符串 */
id: string;
name: string;
/** 项目编码,可空 */
code: string | null;
/** 项目整体进度 0-100 */
progress: number;
/** 我在该项目中的角色名,可空 */
myRole: string | null;
/** 项目计划结束日期 YYYY-MM-DD可空 */
plannedEndDate: string | null;
/** 项目下进行中执行数 */
executionCount: number;
/** 项目下进行中任务数 */
taskCount: number;
/** 项目下逾期任务数 */
overdueCount: number;
/** 项目当前有效成员数(多角色去重) */
memberCount: number;
/** 成员负载列表(无成员为 [] */
members: MyOwnedProjectMember[];
}
/** 创建执行入参(含 ownerId + assigneeUserIds */ /** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams { interface CreateProjectExecutionParams {
executionName: string; executionName: string;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE } from '@/constants/dict'; import { fetchGetOvertimeApplicationStatusDict } from '@/service/api';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue'; import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'OvertimeApplicationSearch' }); defineOptions({ name: 'OvertimeApplicationSearch' });
@@ -21,6 +21,8 @@ const searchModel = reactive<Record<string, any>>({
approverName: '' approverName: ''
}); });
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
let syncingFromSource = false; let syncingFromSource = false;
watch( watch(
@@ -53,6 +55,24 @@ watch(
{ flush: 'sync' } { flush: 'sync' }
); );
async function loadStatusOptions() {
const { error, data } = await fetchGetOvertimeApplicationStatusDict();
if (error || !data) {
statusOptions.value = [];
return;
}
statusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
onMounted(async () => {
await loadStatusOptions();
});
const fields = computed<SearchField[]>(() => [ const fields = computed<SearchField[]>(() => [
{ {
key: 'applicantName', key: 'applicantName',
@@ -69,8 +89,8 @@ const fields = computed<SearchField[]>(() => [
{ {
key: 'statusCode', key: 'statusCode',
label: '状态', label: '状态',
type: 'dict', type: 'select',
dictCode: RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE, options: statusOptions.value,
placeholder: '请选择状态' placeholder: '请选择状态'
}, },
{ {

View File

@@ -10,6 +10,7 @@ import {
RDMS_REQ_PRIORITY_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict'; } from '@/constants/dict';
import { getStatusTagType } from '@/constants/status-tag';
import { import {
fetchChangeRequirementStatus, fetchChangeRequirementStatus,
fetchDeleteRequirement, fetchDeleteRequirement,
@@ -31,7 +32,6 @@ import {
ACTION_TYPE_MAP, ACTION_TYPE_MAP,
type RequirementStatusActionCode, type RequirementStatusActionCode,
getRequirementActionDisplayName, getRequirementActionDisplayName,
getRequirementStatusTagType,
isRequirementActionNeedProject, isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice, isRequirementActionNeedReviewChoice,
isRequirementActionTerminal isRequirementActionTerminal
@@ -375,7 +375,7 @@ const columns = computed(() => [
width: 100, width: 100,
align: 'center', align: 'center',
formatter: (row: Api.Product.Requirement) => ( formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag> <ElTag type={getStatusTagType('productRequirement', row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
) )
}, },
{ {

View File

@@ -90,22 +90,7 @@ export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> =
close: 'danger', close: 'danger',
delete: 'danger' delete: 'danger'
}; };
export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
pending_claim: 'info',
pending_review: 'info',
pending_dispatch: 'primary',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) { export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) {
const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close']; const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close'];
return terminalActions.includes(actionCode); return terminalActions.includes(actionCode);

View File

@@ -1,6 +1,4 @@
import { type Ref, computed, markRaw } from 'vue'; import { markRaw } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { canReportTaskWorklog } from '../shared';
import { useTaskPermissions } from './use-task-permissions'; import { useTaskPermissions } from './use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline'; import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline'; import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
@@ -58,27 +56,21 @@ const STATUS_ACTION_ORDER: Record<string, number> = {
* *
* 表格操作列与看板卡片操作区共用同一份语义:填报 / 编辑 / 删除 / 状态推进按钮, * 表格操作列与看板卡片操作区共用同一份语义:填报 / 编辑 / 删除 / 状态推进按钮,
* 含 auto_start 过滤、complete 进度 100% 兜底、按钮排序与 icon/type 映射。 * 含 auto_start 过滤、complete 进度 100% 兜底、按钮排序与 icon/type 映射。
*
* dataRef 用于填报按钮的"叶子"判定canReportTaskWorklog 需要全量行集合)。
*/ */
export function useTaskActions(dataRef: Ref<Api.Project.ProjectTask[]>, emits: TaskActionEmits) { export function useTaskActions(emits: TaskActionEmits) {
const authStore = useAuthStore(); const { canEditTask, canDeleteTask } = useTaskPermissions();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
function createActions(row: Api.Project.ProjectTask): TaskAction[] { function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = []; const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定 // 工作日志:行操作入口始终显示——查看人人可看;新增/编辑由弹层内 canSubmit 按身份状态控制
if (hasReportWorklogPermission() && canReportTaskWorklog(row, dataRef.value, currentUserId.value)) {
actions.push({ actions.push({
key: 'report', key: 'report',
tooltip: '填报', tooltip: '工作日志',
icon: markRaw(IconMdiClipboardEditOutline), icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary', type: 'primary',
onClick: () => emits.report(row) onClick: () => emits.report(row)
}); });
}
if (canEditTask(row)) { if (canEditTask(row)) {
actions.push({ actions.push({

View File

@@ -118,10 +118,6 @@ export function useTaskPermissions() {
return isTopLevelTask(task) && currentUserId.value === task.executionOwnerId; return isTopLevelTask(task) && currentUserId.value === task.executionOwnerId;
} }
function canReportTaskWorklog(): boolean {
return hasPermission('project:task:worklog');
}
return { return {
// execution // execution
canEditExecution, canEditExecution,
@@ -134,7 +130,6 @@ export function useTaskPermissions() {
canDeleteTask, canDeleteTask,
canCreateTopLevelTask, canCreateTopLevelTask,
canCreateSubTask, canCreateSubTask,
canManageTaskAssignee, canManageTaskAssignee
canReportTaskWorklog
}; };
} }

View File

@@ -684,10 +684,11 @@ async function confirmDeleteExecution(payload: { name: string; confirmText: stri
confirmText: payload.confirmText, confirmText: payload.confirmText,
reason: payload.reason reason: payload.reason
}); });
if (error) return; // 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
window.$message?.success('删除成功'); // 两种情况都关弹层 + 刷新:失败也要让用户离开已失效的弹层、看到最新数据。
deleteDialogVisible.value = false; deleteDialogVisible.value = false;
selectedExecution.value = null; selectedExecution.value = null;
if (!error) window.$message?.success('删除成功');
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷 // 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([ await Promise.all([
reloadExecutionData(1), reloadExecutionData(1),

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
import { VueDraggable } from 'vue-draggable-plus'; import { VueDraggable } from 'vue-draggable-plus';
import { Calendar, Flag, Loading, Lock, User } from '@element-plus/icons-vue'; import { Calendar, Flag, Loading, Lock, User } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
@@ -201,10 +201,7 @@ watch(
); );
// 看板卡片操作按钮(与表格操作列同语义)。 // 看板卡片操作按钮(与表格操作列同语义)。
// 兼容 useTaskActions 的"叶子判定"需求:拍平当前已加载的全部任务做集合。 const { createActions } = useTaskActions({
const allLoadedTasks = computed(() => columns.value.flatMap(item => item.tasks));
const { createActions } = useTaskActions(allLoadedTasks, {
edit: row => emit('edit', row), edit: row => emit('edit', row),
report: row => emit('report', row), report: row => emit('report', row),
remove: row => emit('delete', row), remove: row => emit('delete', row),
@@ -364,7 +361,13 @@ onBeforeUnmount(() => {
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" /> <ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
</div> </div>
<div v-if="createActions(task).length" class="task-board-card-item__actions" @click.stop> <div
v-if="createActions(task).length"
class="task-board-card-item__actions"
@click.stop
@pointerdown.stop
@mousedown.stop
>
<ElTooltip v-for="action in createActions(task)" :key="action.key" :content="action.tooltip"> <ElTooltip v-for="action in createActions(task)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()"> <ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-15px" /> <component :is="action.icon" class="text-15px" />

View File

@@ -6,6 +6,7 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue'; import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue'; import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' }); defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' });
@@ -53,7 +54,7 @@ const parentTaskOptions = computed(() => {
<ElFormItem label="任务类型"> <ElFormItem label="任务类型">
<DictSelect :model-value="taskType" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" disabled placeholder="--" /> <DictSelect :model-value="taskType" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" disabled placeholder="--" />
</ElFormItem> </ElFormItem>
<ElFormItem label="父任务"> <ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无"> <ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无">
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" /> <ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect> </ElSelect>

View File

@@ -10,6 +10,8 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue'; import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue'; import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' }); defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
type OperateMode = 'create' | 'edit'; type OperateMode = 'create' | 'edit';
@@ -342,7 +344,7 @@ defineExpose({
/> />
</ElFormItem> </ElFormItem>
<ElFormItem label="父任务"> <ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务"> <ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务">
<ElOption <ElOption
v-for="item in selectableParentTasks" v-for="item in selectableParentTasks"

View File

@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRef } from 'vue'; import { computed } from 'vue';
import type { PaginationProps } from 'element-plus'; import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import { formatDate, formatDateRange, getTaskStatusName, getTaskStatusTagType } from '../shared'; import {
SHOW_TASK_PARENT_FIELD,
formatDate,
formatDateRange,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskActions } from '../composables/use-task-actions'; import { useTaskActions } from '../composables/use-task-actions';
defineOptions({ name: 'ProjectExecutionTaskTableView' }); defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -50,7 +56,7 @@ function getRoleLabel(row: Api.Project.ProjectTask): { label: string; type: Role
return { label: '旁观', type: undefined }; return { label: '旁观', type: undefined };
} }
const { createActions } = useTaskActions(toRef(props, 'data'), { const { createActions } = useTaskActions({
edit: row => emit('edit', row), edit: row => emit('edit', row),
report: row => emit('report', row), report: row => emit('report', row),
remove: row => emit('delete', row), remove: row => emit('delete', row),
@@ -141,7 +147,12 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip> <ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template> <template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn v-if="!crossExecutionMode" label="父任务" min-width="140" show-overflow-tooltip> <ElTableColumn
v-if="!crossExecutionMode && SHOW_TASK_PARENT_FIELD"
label="父任务"
min-width="140"
show-overflow-tooltip
>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template> <template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="进度" width="160"> <ElTableColumn label="进度" width="160">

View File

@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project'; import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared'; import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared'; import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue'; import TaskWorklogPanel from './task-worklog-panel.vue';
@@ -25,7 +24,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const authStore = useAuthStore(); const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const currentUserId = computed(() => authStore.userInfo.userId || ''); const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value)); const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const isActiveAssignee = computed(() => const isActiveAssignee = computed(() =>
@@ -33,13 +31,11 @@ const isActiveAssignee = computed(() =>
); );
// 工时面板顶部「填报」按钮的可见度与任务行操作列的「填报」按钮同源§4.8.4 矩阵 + 业务事实修正): // 工时面板顶部「填报」按钮的可见度与任务行操作列的「填报」按钮同源§4.8.4 矩阵 + 业务事实修正):
// - 权限码 project:task:worklog // - 身份:任务负责人 OR 活跃协办人(非任务团队成员不显示填报,不再卡 project:task:worklog 权限码)
// - 身份:任务负责人 OR 活跃协办人
// - 状态pending首次填触发 auto_startOR active OR completedcompleted 后填报不回写进度,由 form-dialog 内进度只读兜底) // - 状态pending首次填触发 auto_startOR active OR completedcompleted 后填报不回写进度,由 form-dialog 内进度只读兜底)
// 不做叶子判定——详情入口已锁定单条任务,无父子歧义 // 不做叶子判定——详情入口已锁定单条任务,无父子歧义
const canSubmitWorklog = computed(() => { const canSubmitWorklog = computed(() => {
if (!props.task || !currentUserId.value) return false; if (!props.task || !currentUserId.value) return false;
if (!objectContextStore.buttonCodes.includes('project:task:worklog')) return false;
if (!isOwner.value && !isActiveAssignee.value) return false; if (!isOwner.value && !isActiveAssignee.value) return false;
return ( return (
props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed' props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed'
@@ -60,19 +56,17 @@ const plannedEndText = computed(() => (props.task?.plannedEndDate ? formatDate(p
const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--')); const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--'));
const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--')); const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--'));
// 协办人视角 records 只含自身;责任人视角 records 全员 // 工作日志查看全部开放:不分身份,records 一律含该任务全员
const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0)); const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0));
const totalHoursText = computed(() => { const totalHoursText = computed(() => {
if (recordsLoading.value) return '...'; if (recordsLoading.value) return '...';
return `${totalHours.value.toFixed(1)} h`; return `${totalHours.value.toFixed(1)} h`;
}); });
// 责任人视角下"总工时" hover 展示按用户分组的明细;协办人视角不计算 // "总工时" hover 展示按用户分组的明细(查看全部开放,所有人都看得到)。
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人); // 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
// 没填过工时的显示 0h // 没填过工时的显示 0h
const hoursByUserDetail = computed(() => { const hoursByUserDetail = computed(() => {
if (!isOwner.value) return [];
const sumMap = new Map<string, number>(); const sumMap = new Map<string, number>();
for (const item of records.value) { for (const item of records.value) {
sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0)); sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0));
@@ -122,14 +116,11 @@ async function loadRecords() {
} }
recordsLoading.value = true; recordsLoading.value = true;
// 查看全部开放:不按身份裁剪,所有人一律拉该任务全员工时
const params: Api.Project.TaskWorklogSearchParams = { const params: Api.Project.TaskWorklogSearchParams = {
pageNo: 1, pageNo: 1,
pageSize: -1 pageSize: -1
}; };
// 协办人视角:只看自己的 worklogowner 视角:全量加载
if (!isOwner.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage( const { error, data } = await fetchGetProjectTaskWorklogPage(
props.task.projectId, props.task.projectId,
@@ -186,7 +177,7 @@ watch(
<div class="task-worklog-content__card"> <div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">总工时</span> <span class="task-worklog-content__card-label">总工时</span>
<ElTooltip <ElTooltip
v-if="isOwner && hoursByUserDetail.length > 0" v-if="hoursByUserDetail.length > 0"
placement="top" placement="top"
effect="light" effect="light"
popper-class="task-worklog-content__hours-popper" popper-class="task-worklog-content__hours-popper"
@@ -237,7 +228,7 @@ watch(
:task-progress-rate="task.progressRate" :task-progress-rate="task.progressRate"
:can-submit="canSubmitWorklog" :can-submit="canSubmitWorklog"
:external-list="records" :external-list="records"
:show-assignee-column="isOwner" :show-assignee-column="true"
@changed="handleWorklogChanged" @changed="handleWorklogChanged"
/> />
</div> </div>

View File

@@ -473,7 +473,7 @@ watch(
<template> <template>
<div class="task-worklog-panel"> <div class="task-worklog-panel">
<header v-if="canCreate" class="task-worklog-panel__header"> <header v-if="canCreate" class="task-worklog-panel__header">
<ElButton type="primary" :icon="Plus" size="small" @click="handleCreate">填报</ElButton> <ElButton type="primary" :icon="Plus" size="small" @click="handleCreate">工作日志</ElButton>
</header> </header>
<ElTable <ElTable
@@ -484,7 +484,7 @@ watch(
empty-text="暂无工作日志" empty-text="暂无工作日志"
class="task-worklog-panel__table" class="task-worklog-panel__table"
> >
<ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" /> <ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" fixed="left" />
<ElTableColumn label="粒度" width="70" align="center"> <ElTableColumn label="粒度" width="70" align="center">
<template #default="{ row }"> <template #default="{ row }">
<ElTag <ElTag

View File

@@ -696,10 +696,11 @@ async function confirmDeleteTask(payload: { name: string; confirmText: string; r
confirmText: payload.confirmText, confirmText: payload.confirmText,
reason: payload.reason reason: payload.reason
}); });
if (error) return; // 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
window.$message?.success('删除成功'); // 两种情况都关弹层 + 刷新列表:失败也要让用户离开已失效的弹层、看到最新数据。
deleteTaskDialogVisible.value = false; deleteTaskDialogVisible.value = false;
deleteTaskTarget.value = null; deleteTaskTarget.value = null;
if (!error) window.$message?.success('删除成功');
await Promise.all([refreshTableData(), loadTaskStatusBoard()]); await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
} }

View File

@@ -5,6 +5,13 @@ type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode; type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType; type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType;
/**
* 是否在任务界面展示「父任务」相关露出(表格列 / 新建编辑下拉 / 详情只读字段)。
* 当前业务经执行分层后极少有子任务需求,暂统一隐藏,使任务呈扁平的一级任务列表;
* 底层父子数据与级联完成逻辑保留不动,将来恢复子任务功能改回 true 即可。
*/
export const SHOW_TASK_PARENT_FIELD = false;
export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = { export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = {
join: '加入', join: '加入',
inactive: '失效', inactive: '失效',

View File

@@ -9,6 +9,7 @@ import {
RDMS_REQ_PRIORITY_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict'; } from '@/constants/dict';
import { getStatusTagType } from '@/constants/status-tag';
import { import {
fetchChangeProjectRequirementStatus, fetchChangeProjectRequirementStatus,
fetchDeleteProjectRequirement, fetchDeleteProjectRequirement,
@@ -28,7 +29,6 @@ import {
getProjectRequirementActionButtonType, getProjectRequirementActionButtonType,
getProjectRequirementActionDisplayName, getProjectRequirementActionDisplayName,
getProjectRequirementActionIcon, getProjectRequirementActionIcon,
getProjectRequirementStatusTagType,
isProjectRequirementActionTerminal isProjectRequirementActionTerminal
} from './shared/requirement-master-data'; } from './shared/requirement-master-data';
import RequirementActionDialog from './modules/requirement-action-dialog.vue'; import RequirementActionDialog from './modules/requirement-action-dialog.vue';
@@ -377,7 +377,7 @@ const columns = computed(() => [
width: 110, width: 110,
align: 'center', align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => ( formatter: (row: Api.Project.ProjectRequirement) => (
<ElTag type={getProjectRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag> <ElTag type={getStatusTagType('projectRequirement', row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
) )
}, },
{ {

View File

@@ -77,24 +77,6 @@ function resolveActionKeyword(actionCode: string) {
return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword)); return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword));
} }
/**
* 获取项目需求状态的标签颜色
*/
export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Project.ProjectRequirementStatusCode, UI.ThemeColor> = {
pending_claim: 'info',
pending_review: 'info',
reviewed: 'success',
review_rejected: 'danger',
implementing: 'primary',
accepted: 'success',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
/** /**
* 判断动作是否为终态动作 * 判断动作是否为终态动作
* *

View File

@@ -1,11 +1,11 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { type WorkbenchColumnId, type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules'; import { type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules';
import { buildDefaultLayout } from './workbench-layout-default'; import { buildDefaultLayout } from './workbench-layout-default';
import type { LayoutStorage } from './layout-storage'; import type { LayoutStorage } from './layout-storage';
import { LocalStorageAdapter } from './layout-storage-local'; import { LocalStorageAdapter } from './layout-storage-local';
import { reconcileLayout } from './workbench-layout-reconcile'; import { reconcileLayout } from './workbench-layout-reconcile';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types'; import { WORKBENCH_LAYOUT_VERSION, type WorkbenchGridItem, type WorkbenchLayout } from './workbench-layout-types';
export type WorkbenchMode = 'normal' | 'editing'; export type WorkbenchMode = 'normal' | 'editing';
@@ -56,7 +56,7 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
if (mode.value === 'editing') { if (mode.value === 'editing') {
dirty.value = true; dirty.value = true;
} else { } else {
// 非编辑态写(如折叠)直接落盘 // 非编辑态写直接落盘
persist(); persist();
} }
} }
@@ -91,32 +91,31 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
} }
function hideModule(key: WorkbenchModuleKey) { function hideModule(key: WorkbenchModuleKey) {
for (const col of layout.value.columns) { layout.value.grid = layout.value.grid.filter(item => item.i !== key);
col.modules = col.modules.filter(k => k !== key);
}
if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key); if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key);
markDirty(); markDirty();
} }
function showModule(key: WorkbenchModuleKey, columnId: WorkbenchColumnId = 'left') { function showModule(key: WorkbenchModuleKey) {
if (layout.value.grid.some(item => item.i === key)) return;
layout.value.hidden = layout.value.hidden.filter(k => k !== key); layout.value.hidden = layout.value.hidden.filter(k => k !== key);
const target = layout.value.columns.find(c => c.id === columnId); const meta = getAllModules().find(m => m.key === key);
if (target && !target.modules.includes(key)) target.modules.push(key); if (!meta) return;
const nextY = layout.value.grid.reduce((max, g) => Math.max(max, g.y + g.h), 0);
layout.value.grid.push({
i: key,
x: meta.defaultGrid.x,
y: nextY,
w: meta.defaultGrid.w,
h: meta.defaultGrid.h,
minW: meta.defaultGrid.minW,
minH: meta.defaultGrid.minH
});
markDirty(); markDirty();
} }
function setColumnModules(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) { function updateGrid(grid: WorkbenchGridItem[]) {
const target = layout.value.columns.find(c => c.id === columnId); layout.value.grid = grid;
if (target) target.modules = modules;
markDirty();
}
function toggleCollapse(key: WorkbenchModuleKey) {
if (layout.value.collapsed.includes(key)) {
layout.value.collapsed = layout.value.collapsed.filter(k => k !== key);
} else {
layout.value.collapsed.push(key);
}
markDirty(); markDirty();
} }
@@ -129,15 +128,16 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
} }
async function resetToDefault() { async function resetToDefault() {
layout.value = buildDefaultLayout(getAllModules()); const fresh = buildDefaultLayout(getAllModules());
// 重置只针对布局(位置/尺寸/显隐);用户偏好(如 shortcut.menuKeys原样保留
fresh.settings = { ...layout.value.settings };
layout.value = fresh;
mode.value = 'normal'; mode.value = 'normal';
dirty.value = false; dirty.value = false;
snapshotBeforeEdit = null; snapshotBeforeEdit = null;
await storage.save(options.userId, layout.value); await storage.save(options.userId, layout.value);
} }
const isCollapsed = (key: WorkbenchModuleKey) => layout.value.collapsed.includes(key);
const hiddenMetas = computed(() => { const hiddenMetas = computed(() => {
const allMeta = getAllModules(); const allMeta = getAllModules();
return layout.value.hidden return layout.value.hidden
@@ -152,15 +152,13 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
saving, saving,
error, error,
hiddenMetas, hiddenMetas,
isCollapsed,
load, load,
enterEditing, enterEditing,
saveEditing, saveEditing,
cancelEditing, cancelEditing,
hideModule, hideModule,
showModule, showModule,
setColumnModules, updateGrid,
toggleCollapse,
updateModuleSettings, updateModuleSettings,
resetToDefault resetToDefault
}; };

View File

@@ -11,12 +11,10 @@ export type WorkbenchModuleKey =
| 'myExecution' // B8 · 我负责的执行 | 'myExecution' // B8 · 我负责的执行
| 'productSnapshot' // F24 · 产品深度快照(对象快照 / 当前对象切换) | 'productSnapshot' // F24 · 产品深度快照(对象快照 / 当前对象切换)
| 'teamLoad' // C13 · 团队负载(管理者) | 'teamLoad' // C13 · 团队负载(管理者)
| 'myWeekWorklog' // D16 · 工时(含「我的工时 / 团队工时」两 tab原 C12 teamWorklog 已并入) | 'myWeekWorklog'; // D16 · 工时(含「我的工时 / 团队工时」两 tab原 C12 teamWorklog 已并入)
| 'noticeNotification'; // E22 · 公告 + 通知摘要
// 扩展action动作型 widget、snapshot对象快照型 widget需指定一个对象 // 扩展action动作型 widget、snapshot对象快照型 widget需指定一个对象
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot'; export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot';
export type WorkbenchColumnId = 'left' | 'right';
export interface WorkbenchModuleMeta { export interface WorkbenchModuleMeta {
key: WorkbenchModuleKey; key: WorkbenchModuleKey;
@@ -25,17 +23,17 @@ export interface WorkbenchModuleMeta {
icon: string; icon: string;
category: WorkbenchModuleCategory; category: WorkbenchModuleCategory;
defaultVisible: boolean; defaultVisible: boolean;
defaultColumn: WorkbenchColumnId; /** 默认网格位置与尺寸12 栅格。hidden 项的 x/y 仅作占位show 时动态找空位。 */
defaultOrder: number; defaultGrid: { x: number; y: number; w: number; h: number; minW: number; minH: number };
} }
const placeholder = markRaw({ render: () => null }); const placeholder = markRaw({ render: () => null });
// 默认布局2026-05-27 调整,对应 WORKBENCH_LAYOUT_VERSION=3 // 默认布局2026-06-01 固化用户实拍布局,对应 WORKBENCH_LAYOUT_VERSION=5
// left: myTodo(1) → myExecution(2) // 左列x=0 w=7myTodo(y=0 h=25) → myWeekWorklog(y=25 h=22)
// right: shortcut(1) → myProject(2) → myWeekWorklog(3) → teamLoad(4) // 右列x=7 w=5shortcut(y=0 h=11) → myProject(y=11 h=17) → myExecution(y=28 h=19)
// hidden: projectHealth, noticeNotification, productSnapshot // 底部满宽x=0 w=12teamLoad(y=47 h=16)
// noticeNotification 隐藏原因:公告搬到 banner、通知归全局头部铃铛 // hiddenx/y 为占位show 时动态落到网格底部projectHealth、productSnapshot
const registry: WorkbenchModuleMeta[] = [ const registry: WorkbenchModuleMeta[] = [
{ {
key: 'myTodo', key: 'myTodo',
@@ -44,8 +42,8 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:clipboard-text-clock-outline', icon: 'mdi:clipboard-text-clock-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'left', // minH 24 ≈ 608px保证至少完整展示 5 条待办(头部 124 + 5×71 列表 + 余量)
defaultOrder: 1 defaultGrid: { x: 0, y: 0, w: 7, h: 25, minW: 5, minH: 24 }
}, },
{ {
key: 'myExecution', key: 'myExecution',
@@ -54,8 +52,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:flag-checkered', icon: 'mdi:flag-checkered',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'left', defaultGrid: { x: 7, y: 28, w: 5, h: 19, minW: 4, minH: 15 }
defaultOrder: 2
}, },
{ {
key: 'shortcut', key: 'shortcut',
@@ -64,8 +61,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:rocket-launch-outline', icon: 'mdi:rocket-launch-outline',
category: 'tool', category: 'tool',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 7, y: 0, w: 5, h: 11, minW: 3, minH: 10 }
defaultOrder: 1
}, },
{ {
key: 'myProject', key: 'myProject',
@@ -74,8 +70,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:briefcase-outline', icon: 'mdi:briefcase-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 7, y: 11, w: 5, h: 17, minW: 5, minH: 17 }
defaultOrder: 2
}, },
{ {
key: 'myWeekWorklog', key: 'myWeekWorklog',
@@ -84,8 +79,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:timer-outline', icon: 'mdi:timer-outline',
category: 'personal', category: 'personal',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 0, y: 25, w: 7, h: 22, minW: 6, minH: 18 }
defaultOrder: 3
}, },
{ {
key: 'teamLoad', key: 'teamLoad',
@@ -94,8 +88,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:scale-balance', icon: 'mdi:scale-balance',
category: 'manager', category: 'manager',
defaultVisible: true, defaultVisible: true,
defaultColumn: 'right', defaultGrid: { x: 0, y: 47, w: 12, h: 16, minW: 4, minH: 15 }
defaultOrder: 4
}, },
// === 默认隐藏(用户可从 widget 库拖回) === // === 默认隐藏(用户可从 widget 库拖回) ===
{ {
@@ -105,18 +98,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:heart-pulse', icon: 'mdi:heart-pulse',
category: 'manager', category: 'manager',
defaultVisible: false, defaultVisible: false,
defaultColumn: 'right', defaultGrid: { x: 0, y: 0, w: 5, h: 12, minW: 4, minH: 9 }
defaultOrder: 10
},
{
key: 'noticeNotification',
component: placeholder,
displayName: '公告 + 通知',
icon: 'mdi:bullhorn-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 11
}, },
{ {
key: 'productSnapshot', key: 'productSnapshot',
@@ -125,8 +107,7 @@ const registry: WorkbenchModuleMeta[] = [
icon: 'mdi:image-area-close', icon: 'mdi:image-area-close',
category: 'snapshot', category: 'snapshot',
defaultVisible: false, defaultVisible: false,
defaultColumn: 'left', defaultGrid: { x: 0, y: 0, w: 6, h: 14, minW: 4, minH: 10 }
defaultOrder: 41
} }
]; ];

View File

@@ -0,0 +1,30 @@
import { ref } from 'vue';
/**
* 工作台 widget 统一刷新:卡片右上角刷新按钮触发,转 loading + 执行加载动作,并发期内忽略重复点击。
*
* - 已接真实接口的 widget传入 loader内部 await 拉取并回填数据)。
* - 尚未接接口的 mock widget不传 loader转一拍 loading 给出可感知反馈;接口接通后补 loader 即自动生效。
*/
export function useWorkbenchRefresh(loader?: () => Promise<void> | void) {
const loading = ref(false);
async function refresh() {
if (loading.value) return;
loading.value = true;
try {
if (loader) {
await loader();
} else {
// 占位mock widget 无真实数据源,转一拍 loading接口接通后传入 loader 替代
await new Promise<void>(resolve => {
setTimeout(resolve, 400);
});
}
} finally {
loading.value = false;
}
}
return { loading, refresh };
}

View File

@@ -2,29 +2,38 @@ import type { WorkbenchModuleMeta } from './use-workbench-modules';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types'; import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout { export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const left = modules const grid = modules
.filter(m => m.defaultVisible && m.defaultColumn === 'left') .filter(m => m.defaultVisible)
.sort((a, b) => a.defaultOrder - b.defaultOrder) .map(m => ({
.map(m => m.key); i: m.key,
x: m.defaultGrid.x,
y: m.defaultGrid.y,
w: m.defaultGrid.w,
h: m.defaultGrid.h,
minW: m.defaultGrid.minW,
minH: m.defaultGrid.minH
}));
const right = modules const hidden = modules.filter(m => !m.defaultVisible).map(m => m.key);
.filter(m => m.defaultVisible && m.defaultColumn === 'right')
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
const hidden = modules
.filter(m => !m.defaultVisible)
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
return { return {
version: WORKBENCH_LAYOUT_VERSION, version: WORKBENCH_LAYOUT_VERSION,
columns: [ grid,
{ id: 'left', modules: left },
{ id: 'right', modules: right }
],
hidden, hidden,
collapsed: [], // 默认快捷入口(固化用户实拍选择);已有用户的旧 settings 在 load 时优先迁移,此默认仅作用于全新用户
settings: {} settings: {
shortcut: {
menuKeys: [
'product_list',
'project_list',
'ticket_my-submitted',
'personal-center_my-weekly',
'personal-center_my-monthly',
'personal-center_my-performance',
'personal-center_my-application',
'infra_rd-code'
]
}
}
}; };
} }

View File

@@ -1,31 +1,45 @@
import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules'; import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules';
import type { WorkbenchLayout } from './workbench-layout-types'; import type { WorkbenchGridItem, WorkbenchLayout } from './workbench-layout-types';
/** /**
* 把存量布局与当前模块注册中心对齐。 * 把存量布局与当前模块注册中心对齐。
* - 注册中心存在但布局未含的 key按 defaultVisible 进 columns 或 hidden
* - 布局含但注册中心已删除的 key丢弃 * - 布局含但注册中心已删除的 key丢弃
* - 注册中心存在但布局未含的 key按 defaultVisible 落入网格底部或 hidden
*/ */
export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout { export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key)); const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key));
const filterKnown = (list: WorkbenchModuleKey[]) => list.filter(k => knownKeys.has(k)); const metaByKey = new Map<WorkbenchModuleKey, WorkbenchModuleMeta>(modules.map(m => [m.key, m]));
const columns = layout.columns.map(c => ({ id: c.id, modules: filterKnown(c.modules) })); // 最小宽高是组件固有能力下限,始终以 meta 为准刷新(不被旧存储固化),并把 w/h clamp 到不低于下限
const hidden = filterKnown(layout.hidden); const grid: WorkbenchGridItem[] = layout.grid
const collapsed = filterKnown(layout.collapsed); .filter(item => knownKeys.has(item.i))
.map(item => {
const { minW, minH } = metaByKey.get(item.i)!.defaultGrid;
return { ...item, minW, minH, w: Math.max(item.w, minW), h: Math.max(item.h, minH) };
});
const hidden = layout.hidden.filter(k => knownKeys.has(k));
const appearKeys = new Set<WorkbenchModuleKey>([...columns.flatMap(c => c.modules), ...hidden]); const appearKeys = new Set<WorkbenchModuleKey>([...grid.map(g => g.i), ...hidden]);
for (const m of modules) { let nextY = grid.reduce((max, g) => Math.max(max, g.y + g.h), 0);
if (!appearKeys.has(m.key)) {
// 注册中心存在但布局未含的 key可见的落网格底部其余进 hidden
for (const m of modules.filter(item => !appearKeys.has(item.key))) {
if (m.defaultVisible) { if (m.defaultVisible) {
const target = columns.find(c => c.id === m.defaultColumn) ?? columns[0]; grid.push({
target.modules.push(m.key); i: m.key,
x: m.defaultGrid.x,
y: nextY,
w: m.defaultGrid.w,
h: m.defaultGrid.h,
minW: m.defaultGrid.minW,
minH: m.defaultGrid.minH
});
nextY += m.defaultGrid.h;
} else { } else {
hidden.push(m.key); hidden.push(m.key);
} }
} }
}
return { ...layout, columns, hidden, collapsed }; return { ...layout, grid, hidden };
} }

View File

@@ -1,8 +1,9 @@
import type { WorkbenchColumnId, WorkbenchModuleKey } from './use-workbench-modules'; import type { WorkbenchModuleKey } from './use-workbench-modules';
// v3 (2026-05-27): myProject 移到右列、myExecution 顶替到 left 第 2 位、noticeNotification 默认隐藏(让位给 banner 公告 + 全局铃铛) // v4 (2026-06-01): 两列排序 → 12 栅格自由网格。columns→grid移除 collapsed
// 版本不匹配时 LocalStorageAdapter.load 直接丢弃存量布局走新默认 // v5 (2026-06-01): 固化用户实拍布局为默认(坐标/尺寸 + 默认快捷入口 menuKeys删除 noticeNotification widget
export const WORKBENCH_LAYOUT_VERSION = 3; // 版本不匹配时丢弃旧布局走新默认settings 原样迁移。
export const WORKBENCH_LAYOUT_VERSION = 5;
export interface WorkbenchShortcutSettings { export interface WorkbenchShortcutSettings {
/** 用户在快捷入口里选了哪些菜单 key */ /** 用户在快捷入口里选了哪些菜单 key */
@@ -15,10 +16,20 @@ export interface WorkbenchModuleSettings {
[key: string]: unknown; [key: string]: unknown;
} }
/** 单个 widget 在 12 栅格中的位置与尺寸。i 即 widget key同时作为 grid-layout-plus 标识)。 */
export interface WorkbenchGridItem {
i: WorkbenchModuleKey;
x: number; // 列起点 0-11
y: number; // 行起点
w: number; // 占列数
h: number; // 占行数
minW?: number;
minH?: number;
}
export interface WorkbenchLayout { export interface WorkbenchLayout {
version: typeof WORKBENCH_LAYOUT_VERSION; version: typeof WORKBENCH_LAYOUT_VERSION;
columns: Array<{ id: WorkbenchColumnId; modules: WorkbenchModuleKey[] }>; grid: WorkbenchGridItem[];
hidden: WorkbenchModuleKey[]; hidden: WorkbenchModuleKey[];
collapsed: WorkbenchModuleKey[];
settings: WorkbenchModuleSettings; settings: WorkbenchModuleSettings;
} }

View File

@@ -10,8 +10,6 @@ export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null;
export type WorkbenchTodoPriority = 'high' | 'mid' | 'low'; export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export type WorkbenchProjectStatus = 'active' | 'preview' | 'paused';
export interface WorkbenchKpiSource { export interface WorkbenchKpiSource {
/** 待办 */ /** 待办 */
todo: { todo: {
@@ -96,28 +94,17 @@ export interface WorkbenchActivityItem extends Omit<WorkbenchActivityItemSource,
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet'; tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
} }
export interface WorkbenchProjectItemSource { /** 「我参与的项目」展示项(由 Api.Project.MyParticipatedProjectItem 衍生) */
export interface WorkbenchParticipatedProjectView {
id: string; id: string;
name: string; name: string;
code: string; code: string | null;
status: WorkbenchProjectStatus; statusName: string | null;
/** 我的角色 */
myRole: string;
/** 进度百分比 0-100 */
progress: number;
/** 我负责的任务数 */
myTaskCount: number;
/** 我负责的待处理任务数 */
myPendingTaskCount: number;
/** 最近活动时间ISO */
lastActiveTime: string;
}
export interface WorkbenchProjectItem extends Omit<WorkbenchProjectItemSource, 'lastActiveTime'> {
statusLabel: string;
statusTone: 'sky' | 'emerald' | 'amber'; statusTone: 'sky' | 'emerald' | 'amber';
myRole: string | null;
progress: number; progress: number;
lastActiveLabel: string; myTaskCount: number;
myPendingTaskCount: number;
} }
const todoCategoryMeta: Record< const todoCategoryMeta: Record<
@@ -144,11 +131,12 @@ const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], Workben
product: 'rose' product: 'rose'
}; };
const projectStatusMeta: Record<WorkbenchProjectStatus, { label: string; tone: WorkbenchProjectItem['statusTone'] }> = { /** 列表只含进行中项目;按已知状态编码上色,未知回退 sky */
active: { label: '进行中', tone: 'emerald' }, function resolveParticipatedProjectTone(statusCode: string): 'sky' | 'emerald' | 'amber' {
preview: { label: '试运行', tone: 'sky' }, if (statusCode === 'active') return 'emerald';
paused: { label: '已暂停', tone: 'amber' } if (statusCode === 'paused') return 'amber';
}; return 'sky';
}
function clampPercent(value: number) { function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
@@ -325,61 +313,68 @@ export function buildWorkbenchActivityItems(source: readonly WorkbenchActivityIt
})); }));
} }
export function buildWorkbenchProjectItems(source: readonly WorkbenchProjectItemSource[]): WorkbenchProjectItem[] { export function buildWorkbenchParticipatedProjects(
return source.map(item => { source: readonly Api.Project.MyParticipatedProjectItem[]
const meta = projectStatusMeta[item.status]; ): WorkbenchParticipatedProjectView[] {
return { return source.map(item => ({
...item, id: item.id,
statusLabel: meta.label, name: item.name,
statusTone: meta.tone, code: item.code,
statusName: item.statusName,
statusTone: resolveParticipatedProjectTone(item.statusCode),
myRole: item.myRole,
progress: clampPercent(item.progress), progress: clampPercent(item.progress),
lastActiveLabel: formatRelative(item.lastActiveTime) myTaskCount: item.myTaskCount,
} satisfies WorkbenchProjectItem; myPendingTaskCount: item.myPendingTaskCount
}); }));
} }
export interface WorkbenchOwnedProjectMilestone { /** 「我负责的项目」成员负载展示项 */
id: string; export interface WorkbenchOwnedProjectMemberView {
title: string; userId: string;
timeLabel: string; userName: string | null;
tone: 'amber' | 'slate'; /** 该成员在本项目下进行中任务数 */
activeTaskCount: number;
} }
export interface WorkbenchOwnedProjectMember { /** 「我负责的项目」展示项(由 Api.Project.MyOwnedProjectItem 衍生) */
name: string; export interface WorkbenchOwnedProjectView {
/** 负载 0-100百分比 */
load: number;
level: 'ok' | 'warn' | 'over';
}
export interface WorkbenchOwnedProjectItemSource {
id: string; id: string;
name: string; name: string;
code: string; code: string | null;
/** 进度 0-100 */
progress: number; progress: number;
myRole: string | null;
executionCount: number; executionCount: number;
taskCount: number; taskCount: number;
memberCount: number;
overdueCount: number; overdueCount: number;
/** 距离计划结束剩余天数(负数表示已逾期) */ memberCount: number;
remainingDays: number; /** 计划结束日期 YYYY-MM-DD可空 */
/** 我在该项目中的角色 */ plannedEndDate: string | null;
myRole: string; /** 距计划结束剩余天数(负=已逾期plannedEndDate 为空时 null */
milestones: WorkbenchOwnedProjectMilestone[]; remainingDays: number | null;
members: WorkbenchOwnedProjectMember[]; members: WorkbenchOwnedProjectMemberView[];
} }
export interface WorkbenchOwnedProjectItem extends WorkbenchOwnedProjectItemSource { export function buildWorkbenchOwnedProjects(
progress: number; source: readonly Api.Project.MyOwnedProjectItem[]
} ): WorkbenchOwnedProjectView[] {
export function buildWorkbenchOwnedProjectItems(
source: readonly WorkbenchOwnedProjectItemSource[]
): WorkbenchOwnedProjectItem[] {
return source.map(item => ({ return source.map(item => ({
...item, id: item.id,
progress: clampPercent(item.progress) name: item.name,
code: item.code,
progress: clampPercent(item.progress),
myRole: item.myRole,
executionCount: item.executionCount,
taskCount: item.taskCount,
overdueCount: item.overdueCount,
memberCount: item.memberCount,
plannedEndDate: item.plannedEndDate,
remainingDays: getRemainingDays(item.plannedEndDate),
members: item.members.map(member => ({
userId: member.userId,
userName: member.userName,
activeTaskCount: member.activeTaskCount
}))
})); }));
} }
@@ -759,38 +754,13 @@ export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBar
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) })); return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
} }
export interface WorkbenchMyExecutionItemSource { /**
id: string; * 前端兜底过滤:剔除已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现)。
executionName: string; * 后端接口已按此口径过滤,此处为双保险;泛型保证接口返回类型可复用。
/** 关联项目 */ */
projectId: string; export function buildWorkbenchMyExecutionItems<T extends { statusCode: string; progressRate: number }>(
projectName: string; source: readonly T[]
/** 执行状态编码projectExecution 域pending / active / paused / completed / cancelled */ ): T[] {
statusCode: string;
/** 状态名(后端字典返回) */
statusName: string;
/** 优先级编码(取 RDMS_REQ_PRIORITY_DICT_CODE 字典) */
priority: string;
/** 计划起止 */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止 */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 ID可选 */
projectRequirementId?: string;
/** 关联项目需求名称(可选) */
projectRequirementName?: string;
}
export type WorkbenchMyExecutionItem = WorkbenchMyExecutionItemSource;
/** 过滤掉已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现) */
export function buildWorkbenchMyExecutionItems(
source: readonly WorkbenchMyExecutionItemSource[]
): WorkbenchMyExecutionItem[] {
return source.filter(item => { return source.filter(item => {
if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false; if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false;
if (item.progressRate >= 100) return false; if (item.progressRate >= 100) return false;

View File

@@ -1,15 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import { useWorkbenchStore } from '@/store/modules/workbench'; import { useWorkbenchStore } from '@/store/modules/workbench';
import { import { type WorkbenchModuleKey, useWorkbenchModules } from './composables/use-workbench-modules';
type WorkbenchColumnId, import type { WorkbenchGridItem } from './composables/workbench-layout-types';
type WorkbenchModuleKey,
useWorkbenchModules
} from './composables/use-workbench-modules';
import WorkbenchBanner from './modules/workbench-banner.vue'; import WorkbenchBanner from './modules/workbench-banner.vue';
import WorkbenchColumn from './modules/workbench-column.vue';
import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue'; import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue'; import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
// 保留 6 个 + 重构 2 个key 沿用) // 保留 6 个 + 重构 2 个key 沿用)
@@ -22,11 +19,10 @@ import WorkbenchMyExecution from './modules/workbench-my-execution.vue';
import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue'; import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue';
import WorkbenchTeamLoad from './modules/workbench-team-load.vue'; import WorkbenchTeamLoad from './modules/workbench-team-load.vue';
import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue'; import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue';
import WorkbenchNoticeNotification from './modules/workbench-notice-notification.vue';
defineOptions({ name: 'Workbench' }); defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules(); const { registerModuleComponent, getModuleMeta } = useWorkbenchModules();
// 保留 6 个 + 重构 2 个 // 保留 6 个 + 重构 2 个
registerModuleComponent('myTodo', WorkbenchTodoPanel); registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myProject', WorkbenchProjectGrid); registerModuleComponent('myProject', WorkbenchProjectGrid);
@@ -37,7 +33,6 @@ registerModuleComponent('myExecution', WorkbenchMyExecution);
registerModuleComponent('productSnapshot', WorkbenchProductSnapshot); registerModuleComponent('productSnapshot', WorkbenchProductSnapshot);
registerModuleComponent('teamLoad', WorkbenchTeamLoad); registerModuleComponent('teamLoad', WorkbenchTeamLoad);
registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog); registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog);
registerModuleComponent('noticeNotification', WorkbenchNoticeNotification);
const workbench = useWorkbenchStore(); const workbench = useWorkbenchStore();
const libraryOpen = ref(false); const libraryOpen = ref(false);
@@ -65,8 +60,10 @@ watch(
} }
); );
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) { const editing = computed(() => workbench.mode === 'editing');
workbench.setColumnModules(columnId, modules);
function onGridUpdated(grid: WorkbenchGridItem[]) {
workbench.updateGrid(grid);
} }
async function handleReset() { async function handleReset() {
@@ -107,30 +104,51 @@ onBeforeRouteLeave(async (_to, _from, next) => {
@open-library="libraryOpen = true" @open-library="libraryOpen = true"
/> />
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块"> <ElEmpty v-if="workbench.layout.grid.length === 0" description="还没有可见模块">
<ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton> <ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton>
</ElEmpty> </ElEmpty>
<section v-else class="workbench__main"> <div v-else class="workbench__main">
<WorkbenchColumn <GridLayout
v-for="col in workbench.layout.columns" :layout="workbench.layout.grid"
:key="col.id" :col-num="12"
:column-id="col.id" :row-height="10"
:modules="col.modules" :margin="[16, 16]"
:editing="workbench.mode === 'editing'" :is-draggable="editing"
:collapsed="workbench.layout.collapsed" :is-resizable="editing"
@update:modules="onColumnUpdate(col.id, $event)" :vertical-compact="true"
@hide="workbench.hideModule" :use-css-transforms="true"
@toggle-collapse="workbench.toggleCollapse" @layout-updated="onGridUpdated"
>
<GridItem
v-for="item in workbench.layout.grid"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
:min-w="item.minW"
:min-h="item.minH"
drag-allow-from=".module-card__head"
>
<component
:is="getModuleMeta(item.i)?.component"
:module-key="item.i"
:editing="editing"
@hide="workbench.hideModule(item.i as WorkbenchModuleKey)"
@open-settings="() => {}"
/> />
</section> </GridItem>
</GridLayout>
</div>
<WorkbenchModuleLibrary <WorkbenchModuleLibrary
v-model="libraryOpen" v-model="libraryOpen"
:hidden-metas="workbench.hiddenMetas" :hidden-metas="workbench.hiddenMetas"
@add-module=" @add-module="
(key, col) => { key => {
workbench.showModule(key, col); workbench.showModule(key);
libraryOpen = false; libraryOpen = false;
} }
" "
@@ -143,15 +161,9 @@ onBeforeRouteLeave(async (_to, _from, next) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-x: auto;
} }
.workbench__main { .workbench__main {
display: grid; min-width: 1100px;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 1280px) {
.workbench__main {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@@ -2,12 +2,9 @@ import dayjs from 'dayjs';
import type { import type {
WorkbenchActivityItemSource, WorkbenchActivityItemSource,
WorkbenchKpiSource, WorkbenchKpiSource,
WorkbenchMyExecutionItemSource,
WorkbenchMyWeekWorklogSource, WorkbenchMyWeekWorklogSource,
WorkbenchOwnedProjectItemSource,
WorkbenchProgressBarSource, WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource, WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamLoadSource, WorkbenchTeamLoadSource,
WorkbenchTeamWorklogSource, WorkbenchTeamWorklogSource,
WorkbenchTodoItemSource WorkbenchTodoItemSource
@@ -208,245 +205,6 @@ export const workbenchActivityMock = [
} }
] satisfies WorkbenchActivityItemSource[]; ] satisfies WorkbenchActivityItemSource[];
export const workbenchMyExecutionMock = [
// 商城 V2 升级 · 3 条(分组测试主项目)
{
id: 'exec-1',
executionName: '迭代 24.05 · 后端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(10, 'day').startOf('day')),
plannedEndDate: iso(now.add(3, 'day').endOf('day')),
actualStartDate: iso(now.subtract(8, 'day').startOf('day')),
actualEndDate: null,
progressRate: 68,
projectRequirementId: 'req-mall-001',
projectRequirementName: '订单履约后端拆分(一期)'
},
{
id: 'exec-2',
executionName: '会员等级提示文案',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '3',
plannedStartDate: iso(now.subtract(4, 'day').startOf('day')),
plannedEndDate: iso(now.add(6, 'day').endOf('day')),
actualStartDate: iso(now.subtract(3, 'day').startOf('day')),
actualEndDate: null,
progressRate: 25,
projectRequirementId: 'req-mall-002',
projectRequirementName: '会员等级 UI 升级'
},
{
id: 'exec-3',
executionName: '订单退款流程拆分',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'paused',
statusName: '已暂停',
priority: '2',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.add(10, 'day').endOf('day')),
actualStartDate: iso(now.subtract(15, 'day').startOf('day')),
actualEndDate: null,
progressRate: 50
},
// 风控引擎 · 2 条(含一条计划已过期)
{
id: 'exec-4',
executionName: '关键路径优化',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(2, 'day').endOf('day')),
actualStartDate: iso(now.subtract(18, 'day').startOf('day')),
actualEndDate: null,
progressRate: 42,
projectRequirementId: 'req-risk-001',
projectRequirementName: '风控决策链路压缩'
},
{
id: 'exec-5',
executionName: '黑名单规则改造',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'pending',
statusName: '待开始',
priority: '3',
plannedStartDate: iso(now.add(5, 'day').startOf('day')),
plannedEndDate: iso(now.add(20, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0
},
// 收银台 V3 · 1 条
{
id: 'exec-6',
executionName: '多币种支持 · 计算引擎',
projectId: 'prj-cashier',
projectName: '收银台 V3',
statusCode: 'pending',
statusName: '待开始',
priority: '2',
plannedStartDate: iso(now.add(2, 'day').startOf('day')),
plannedEndDate: iso(now.add(15, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
projectRequirementId: 'req-cashier-001',
projectRequirementName: '多币种结算(含汇率快照)'
},
// 订单中心 · 1 条
{
id: 'exec-7',
executionName: '订单导出 V2',
projectId: 'prj-order',
projectName: '订单中心',
statusCode: 'active',
statusName: '进行中',
priority: '4',
plannedStartDate: iso(now.subtract(15, 'day').startOf('day')),
plannedEndDate: iso(now.add(7, 'day').endOf('day')),
actualStartDate: iso(now.subtract(12, 'day').startOf('day')),
actualEndDate: null,
progressRate: 35
},
// 已完成 —— builder 应过滤掉
{
id: 'exec-8',
executionName: '上一迭代 · 前端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'completed',
statusName: '已完成',
priority: '3',
plannedStartDate: iso(now.subtract(40, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(15, 'day').endOf('day')),
actualStartDate: iso(now.subtract(38, 'day').startOf('day')),
actualEndDate: iso(now.subtract(14, 'day').endOf('day')),
progressRate: 100
},
// 已取消 —— builder 应过滤掉
{
id: 'exec-9',
executionName: '促销活动 · 春节专题',
projectId: 'prj-marketing',
projectName: '营销中台',
statusCode: 'cancelled',
statusName: '已取消',
priority: '3',
plannedStartDate: iso(now.subtract(30, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(10, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 15
},
// 进度 100 但状态未扭转 —— builder 应过滤掉
{
id: 'exec-10',
executionName: '风控规则升级(待扭转)',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '2',
plannedStartDate: iso(now.subtract(8, 'day').startOf('day')),
plannedEndDate: iso(now.add(1, 'day').endOf('day')),
actualStartDate: iso(now.subtract(6, 'day').startOf('day')),
actualEndDate: null,
progressRate: 100
}
] satisfies WorkbenchMyExecutionItemSource[];
export const workbenchOwnedProjectMock = [
{
id: 'p1',
name: '商城 V2 升级',
code: 'MALL-V2',
progress: 70,
executionCount: 5,
taskCount: 32,
memberCount: 6,
overdueCount: 1,
remainingDays: 12,
myRole: '项目负责人',
milestones: [
{ id: 'm1', title: 'SSO 改造提测', timeLabel: '今日 18:00', tone: 'amber' },
{ id: 'm2', title: '迭代 24.05 关闭', timeLabel: '今日', tone: 'amber' },
{ id: 'm3', title: '多币种支持评审', timeLabel: '05-26', tone: 'slate' }
],
members: [
{ name: '张三', load: 50, level: 'ok' },
{ name: '李四', load: 30, level: 'ok' },
{ name: '王五', load: 90, level: 'over' }
]
},
{
id: 'p2',
name: '风控引擎接入',
code: 'RISK-ENGINE',
progress: 45,
executionCount: 3,
taskCount: 18,
memberCount: 4,
overdueCount: 2,
remainingDays: 30,
myRole: '项目负责人',
milestones: [
{ id: 'm4', title: '分片设计评审', timeLabel: '明日', tone: 'amber' },
{ id: 'm5', title: '缓存穿透优化交付', timeLabel: '05-28', tone: 'slate' }
],
members: [
{ name: '李四', load: 30, level: 'ok' },
{ name: '钱七', load: 65, level: 'warn' }
]
}
] satisfies WorkbenchOwnedProjectItemSource[];
export const workbenchProjectMock = [
{
id: 'prj-1',
name: '收银台 V3',
code: 'CASHIER-V3',
status: 'active',
myRole: '项目负责人',
progress: 72,
myTaskCount: 6,
myPendingTaskCount: 2,
lastActiveTime: iso(now.subtract(35, 'minute'))
},
{
id: 'prj-2',
name: '会员中心',
code: 'MEMBER',
status: 'active',
myRole: '后端负责人',
progress: 58,
myTaskCount: 4,
myPendingTaskCount: 1,
lastActiveTime: iso(now.subtract(3, 'hour'))
},
{
id: 'prj-3',
name: '订单中心',
code: 'ORDER-CENTER',
status: 'preview',
myRole: '产品经理',
progress: 95,
myTaskCount: 4,
myPendingTaskCount: 0,
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
}
] satisfies WorkbenchProjectItemSource[];
const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD'); const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD');
const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD'); const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD');

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { VueDraggable } from 'vue-draggable-plus';
import type { WorkbenchColumnId, WorkbenchModuleKey } from '../composables/use-workbench-modules';
import { useWorkbenchModules } from '../composables/use-workbench-modules';
interface Props {
columnId: WorkbenchColumnId;
modules: WorkbenchModuleKey[];
editing: boolean;
collapsed: WorkbenchModuleKey[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modules', modules: WorkbenchModuleKey[]): void;
(e: 'hide', key: WorkbenchModuleKey): void;
(e: 'toggle-collapse', key: WorkbenchModuleKey): void;
(e: 'open-settings', key: WorkbenchModuleKey): void;
}>();
const { getModuleMeta } = useWorkbenchModules();
const modelValue = computed({
get: () => props.modules,
set: (val: WorkbenchModuleKey[]) => emit('update:modules', val)
});
</script>
<template>
<VueDraggable
v-model="modelValue"
group="workbench-modules"
:animation="180"
handle=".module-drag-handle"
:disabled="!editing"
class="workbench-column"
>
<template v-for="key in modelValue" :key="key">
<component
:is="getModuleMeta(key)?.component"
:module-key="key"
:editing="editing"
:collapsed="collapsed.includes(key)"
@hide="emit('hide', key)"
@toggle-collapse="emit('toggle-collapse', key)"
@open-settings="emit('open-settings', key)"
/>
</template>
</VueDraggable>
</template>
<style scoped>
.workbench-column {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 200px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject } from 'vue'; import { inject } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' }); defineOptions({ name: 'WorkbenchModuleCard' });
@@ -12,31 +12,25 @@ interface Props {
icon?: string; icon?: string;
badgeCount?: number; badgeCount?: number;
editing?: boolean; editing?: boolean;
collapsed?: boolean;
hasSettings?: boolean; hasSettings?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
icon: undefined, icon: undefined,
badgeCount: undefined, badgeCount: undefined,
editing: false, editing: false,
collapsed: false,
hasSettings: false hasSettings: false
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle-collapse'): void;
(e: 'hide'): void; (e: 'hide'): void;
(e: 'open-settings'): void; (e: 'open-settings'): void;
(e: 'refresh'): void; (e: 'refresh'): void;
(e: 'navigate'): void;
}>(); }>();
const showBody = computed(() => !props.collapsed);
</script> </script>
<template> <template>
<section class="module-card" :class="{ 'is-editing': editing, 'is-collapsed': collapsed }"> <section class="module-card" :class="{ 'is-editing': editing }">
<header class="module-card__head"> <header class="module-card__head">
<span v-if="editing" class="module-drag-handle" title="拖动调整位置"> <span v-if="editing" class="module-drag-handle" title="拖动调整位置">
<SvgIcon icon="mdi:drag-vertical" /> <SvgIcon icon="mdi:drag-vertical" />
@@ -49,21 +43,9 @@ const showBody = computed(() => !props.collapsed);
<ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')"> <ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')">
<SvgIcon icon="mdi:cog-outline" /> <SvgIcon icon="mdi:cog-outline" />
</ElButton> </ElButton>
<ElButton
v-if="!editing"
link
size="small"
:title="collapsed ? '展开' : '折叠'"
@click="emit('toggle-collapse')"
>
<SvgIcon :icon="collapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" />
</ElButton>
<ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')"> <ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')">
<SvgIcon icon="mdi:refresh" /> <SvgIcon icon="mdi:refresh" />
</ElButton> </ElButton>
<ElButton v-if="!editing" link size="small" title="跳详情" @click="emit('navigate')">
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
<ElButton v-if="!editing && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()"> <ElButton v-if="!editing && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()">
<SvgIcon icon="mdi:view-dashboard-edit-outline" /> <SvgIcon icon="mdi:view-dashboard-edit-outline" />
</ElButton> </ElButton>
@@ -73,7 +55,7 @@ const showBody = computed(() => !props.collapsed);
</div> </div>
</header> </header>
<div v-show="showBody" class="module-card__body"> <div class="module-card__body">
<slot /> <slot />
</div> </div>
</section> </section>
@@ -84,7 +66,7 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-bg-color); background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
border-radius: 10px; border-radius: 10px;
min-height: 180px; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -98,10 +80,6 @@ const showBody = computed(() => !props.collapsed);
border-color: var(--el-color-primary-light-5); border-color: var(--el-color-primary-light-5);
} }
.module-card.is-collapsed {
min-height: auto;
}
.module-card__head { .module-card__head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -111,10 +89,6 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-fill-color-blank); background: var(--el-fill-color-blank);
} }
.module-card.is-collapsed .module-card__head {
border-bottom: none;
}
.module-drag-handle { .module-drag-handle {
cursor: grab; cursor: grab;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -153,7 +127,10 @@ const showBody = computed(() => !props.collapsed);
.module-card__body { .module-card__body {
flex: 1; flex: 1;
min-height: 0;
padding: 14px; padding: 14px;
overflow: auto; overflow: auto;
display: flex;
flex-direction: column;
} }
</style> </style>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { import type { WorkbenchModuleCategory, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
interface Props { interface Props {
modelValue: boolean; modelValue: boolean;
@@ -13,7 +9,7 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void; (e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void; (e: 'add-module', key: WorkbenchModuleMeta['key']): void;
}>(); }>();
// 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具 // 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具
@@ -57,18 +53,13 @@ const groups = computed<Array<{ key: LibraryGroupKey; label: string; items: Work
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<template #default> <template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p> <p class="hint">点击下方模块加入工作台落到网格底部可在编辑态拖动调整</p>
<div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div> <div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div>
<div v-else class="library"> <div v-else class="library">
<section v-for="group in groups" :key="group.key" class="library-group"> <section v-for="group in groups" :key="group.key" class="library-group">
<h4 class="library-group__title">{{ group.label }}</h4> <h4 class="library-group__title">{{ group.label }}</h4>
<ul class="library-group__list"> <ul class="library-group__list">
<li <li v-for="meta in group.items" :key="meta.key" class="library-item" @click="emit('add-module', meta.key)">
v-for="meta in group.items"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<SvgIcon :icon="meta.icon" /> <SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span> <span class="library-item__name">{{ meta.displayName }}</span>
</li> </li>

View File

@@ -1,30 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict'; import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context'; import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyExecutionPage } from '@/service/api';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared'; import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared';
import { type WorkbenchMyExecutionItem, buildWorkbenchMyExecutionItems } from '../homepage'; import { buildWorkbenchMyExecutionItems } from '../homepage';
import { workbenchMyExecutionMock } from '../mock'; import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' }); defineOptions({ name: 'WorkbenchMyExecution' });
type MyExecutionItem = Api.Project.MyExecutionItem;
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const router = useRouter(); const router = useRouter();
const items = computed(() => buildWorkbenchMyExecutionItems(workbenchMyExecutionMock)); const items = ref<MyExecutionItem[]>([]);
const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉取全部当前用户负责的进行中执行;状态/进度过滤由后端完成
const { data, error } = await fetchGetMyExecutionPage({ pageNo: 1, pageSize: -1 });
if (error) return;
items.value = buildWorkbenchMyExecutionItems(data?.list ?? []);
});
onMounted(refresh);
// 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前) // 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前)
const groups = computed<Array<{ projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>>(() => { const groups = computed<Array<{ projectId: string; projectName: string; items: MyExecutionItem[] }>>(() => {
const map = new Map<string, { projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>(); const map = new Map<string, { projectId: string; projectName: string; items: MyExecutionItem[] }>();
items.value.forEach(item => { items.value.forEach(item => {
if (!map.has(item.projectId)) { if (!map.has(item.projectId)) {
map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] }); map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] });
@@ -42,6 +53,26 @@ const groups = computed<Array<{ projectId: string; projectName: string; items: W
return groupsArr.sort((a, b) => b.items.length - a.items.length); return groupsArr.sort((a, b) => b.items.length - a.items.length);
}); });
// 手风琴:单开,默认展开第一个项目(执行最多);展开项消失时回退到第一个
const expandedProjectId = ref<string>('');
watch(
groups,
list => {
if (!list.length) {
expandedProjectId.value = '';
return;
}
if (!list.some(g => g.projectId === expandedProjectId.value)) {
expandedProjectId.value = list[0].projectId;
}
},
{ immediate: true }
);
function toggleProject(projectId: string) {
expandedProjectId.value = expandedProjectId.value === projectId ? '' : projectId;
}
function goProjectExecutionPool(projectId: string) { function goProjectExecutionPool(projectId: string) {
router.push({ router.push({
path: '/project/project/execution', path: '/project/project/execution',
@@ -49,7 +80,7 @@ function goProjectExecutionPool(projectId: string) {
}); });
} }
function goRequirementDetail(item: WorkbenchMyExecutionItem) { function goRequirementDetail(item: MyExecutionItem) {
if (!item.projectRequirementId) return; if (!item.projectRequirementId) return;
router.push({ router.push({
path: '/project/project/requirement', path: '/project/project/requirement',
@@ -67,27 +98,43 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
icon="mdi:flag-checkered" icon="mdi:flag-checkered"
:badge-count="items.length" :badge-count="items.length"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div v-if="items.length" class="exec-groups"> <div v-if="items.length" v-loading="loading" class="exec-groups">
<section v-for="group in groups" :key="group.projectId" class="exec-group"> <section
<header class="exec-group__head"> v-for="group in groups"
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" /> :key="group.projectId"
<span class="exec-group"
class="exec-group__name" :class="{ 'is-open': expandedProjectId === group.projectId }"
>
<header
class="exec-group__head"
role="button" role="button"
tabindex="0" tabindex="0"
:title="`进入「${group.projectName}」执行池`" :aria-expanded="expandedProjectId === group.projectId"
@click="goProjectExecutionPool(group.projectId)" @click="toggleProject(group.projectId)"
@keydown.enter.prevent="goProjectExecutionPool(group.projectId)" @keydown.enter.prevent="toggleProject(group.projectId)"
> >
{{ group.projectName }} <SvgIcon
</span> icon="mdi:chevron-right"
class="exec-group__chevron"
:class="{ 'is-open': expandedProjectId === group.projectId }"
/>
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
<span class="exec-group__name" :title="group.projectName">{{ group.projectName }}</span>
<span class="exec-group__count">{{ group.items.length }}</span> <span class="exec-group__count">{{ group.items.length }}</span>
<ElButton
link
size="small"
class="exec-group__go"
:title="`进入「${group.projectName}」执行池`"
@click.stop="goProjectExecutionPool(group.projectId)"
>
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
</header> </header>
<ul class="exec-list"> <ul v-show="expandedProjectId === group.projectId" class="exec-list">
<li v-for="item in group.items" :key="item.id" class="exec-item"> <li v-for="item in group.items" :key="item.id" class="exec-item">
<div class="exec-head"> <div class="exec-head">
<span class="exec-name" :title="item.executionName">{{ item.executionName }}</span> <span class="exec-name" :title="item.executionName">{{ item.executionName }}</span>
@@ -122,6 +169,7 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</div> </div>
<div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row"> <div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row">
<SvgIcon icon="mdi:link-variant" class="exec-meta__icon" /> <SvgIcon icon="mdi:link-variant" class="exec-meta__icon" />
<span class="exec-meta__label">需求</span>
<span <span
class="exec-meta__text exec-meta__link" class="exec-meta__text exec-meta__link"
role="button" role="button"
@@ -139,28 +187,66 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</ul> </ul>
</section> </section>
</div> </div>
<ElEmpty v-else description="暂无进行中的执行" :image-size="60" /> <div v-else v-loading="loading" class="exec-empty">
<ElEmpty description="暂无进行中的执行" :image-size="60" />
</div>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
<style scoped> <style scoped>
.exec-groups { .exec-groups {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 6px;
}
.exec-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
} }
.exec-group__head { .exec-group__head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-bottom: 8px; padding: 8px 10px;
padding: 0 2px 6px; border-radius: 6px;
border-bottom: 1px dashed var(--el-border-color-lighter); /* 常驻分组底色,明显区别于下方执行项卡片 */
background: var(--el-fill-color-light);
border-left: 3px solid transparent;
cursor: pointer;
user-select: none;
transition:
background 0.16s ease,
border-color 0.16s ease;
}
.exec-group__head:hover,
.exec-group__head:focus-visible {
background: var(--el-fill-color);
outline: none;
}
.exec-group.is-open > .exec-group__head {
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.exec-group__chevron {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
transition: transform 0.18s ease;
}
.exec-group__chevron.is-open {
transform: rotate(90deg);
} }
.exec-group__icon { .exec-group__icon {
flex-shrink: 0; flex-shrink: 0;
font-size: 14px; font-size: 15px;
color: var(--el-text-color-secondary); /* 项目 icon 用主色,与项目管理业务域图标一致且更醒目 */
color: var(--el-color-primary);
} }
.exec-group__name { .exec-group__name {
flex: 1; flex: 1;
@@ -168,17 +254,16 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 13px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
color: var(--el-text-color-regular); color: var(--el-text-color-primary);
letter-spacing: 0.01em; letter-spacing: 0.01em;
cursor: pointer;
transition: color 0.16s ease;
} }
.exec-group__name:hover, .exec-group.is-open .exec-group__name {
.exec-group__name:focus-visible {
color: var(--el-color-primary); color: var(--el-color-primary);
outline: none; }
.exec-group__go {
flex-shrink: 0;
} }
.exec-group__count { .exec-group__count {
flex-shrink: 0; flex-shrink: 0;
@@ -195,8 +280,8 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
} }
.exec-list { .exec-list {
list-style: none; list-style: none;
margin: 0; margin: 4px 0 2px;
padding: 0; padding: 0 2px 0 22px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -252,6 +337,10 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
font-size: 13px; font-size: 13px;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
} }
.exec-meta__label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.exec-meta__text { .exec-meta__text {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View File

@@ -13,19 +13,21 @@ import {
buildWorkbenchWeekWorklogView buildWorkbenchWeekWorklogView
} from '../homepage'; } from '../homepage';
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock'; import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' }); defineOptions({ name: 'WorkbenchMyWeekWorklog' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const router = useRouter(); const router = useRouter();
const { loading, refresh } = useWorkbenchRefresh();
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。 // EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。 // 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) { function resolveIsoWeekStart(weekDate: Date | null) {
@@ -302,12 +304,12 @@ watch(activeTab, async tab => {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="工时" title="工时"
icon="mdi:timer-outline" icon="mdi:timer-outline"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="ww-tabbar"> <div class="ww-tabbar">
<ElTabs v-model="activeTab" class="ww-tabs"> <ElTabs v-model="activeTab" class="ww-tabs">
@@ -327,7 +329,7 @@ watch(activeTab, async tab => {
</div> </div>
<!-- ============ 我的工时 tab ============ --> <!-- ============ 我的工时 tab ============ -->
<div v-show="activeTab === 'my'"> <div v-show="activeTab === 'my'" class="ww-tab-content">
<template v-if="myView"> <template v-if="myView">
<div class="ww-headline"> <div class="ww-headline">
<div class="ww-section-title"> <div class="ww-section-title">
@@ -375,7 +377,7 @@ watch(activeTab, async tab => {
</div> </div>
<!-- ============ 团队工时 tab ============ --> <!-- ============ 团队工时 tab ============ -->
<div v-show="activeTab === 'team'"> <div v-show="activeTab === 'team'" class="ww-tab-content">
<template v-if="teamView"> <template v-if="teamView">
<div class="tw-kpis"> <div class="tw-kpis">
<div class="tw-kpi"> <div class="tw-kpi">
@@ -444,6 +446,19 @@ watch(activeTab, async tab => {
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
/* tab 内容区填充剩余高度flex 列布局,图表区自适应撑满,不写死高度、不内部滚动 */
.ww-tab-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ww-tab-content :deep(.el-empty) {
margin: auto;
} }
.ww-tabs { .ww-tabs {
flex: 1; flex: 1;
@@ -468,12 +483,15 @@ watch(activeTab, async tab => {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 10px; margin-bottom: 10px;
flex-shrink: 0;
} }
.ww-grid { .ww-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px; gap: 16px;
flex: 1;
min-height: 0;
} }
@media (width <= 520px) { @media (width <= 520px) {
.ww-grid { .ww-grid {
@@ -488,6 +506,7 @@ watch(activeTab, async tab => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
} }
.ww-section-title { .ww-section-title {
@@ -508,7 +527,8 @@ watch(activeTab, async tab => {
.ww-pie-wrap { .ww-pie-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
height: 280px; flex: 1;
min-height: 0;
} }
.ww-pie { .ww-pie {
width: 100%; width: 100%;
@@ -517,7 +537,8 @@ watch(activeTab, async tab => {
.ww-bar { .ww-bar {
width: 100%; width: 100%;
height: 280px; flex: 1;
min-height: 0;
} }
.ww-bar-legend { .ww-bar-legend {
display: flex; display: flex;
@@ -526,6 +547,7 @@ watch(activeTab, async tab => {
margin-top: 8px; margin-top: 8px;
font-size: 11px; font-size: 11px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
flex-shrink: 0;
} }
.ww-bar-legend__item { .ww-bar-legend__item {
display: inline-flex; display: inline-flex;
@@ -547,6 +569,7 @@ watch(activeTab, async tab => {
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
font-size: 13px; font-size: 13px;
flex-shrink: 0;
} }
.ww-footer b { .ww-footer b {
font-weight: 700; font-weight: 700;
@@ -570,6 +593,7 @@ watch(activeTab, async tab => {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
flex-shrink: 0;
} }
.tw-kpi { .tw-kpi {
display: flex; display: flex;
@@ -609,7 +633,8 @@ watch(activeTab, async tab => {
.tw-bar { .tw-bar {
width: 100%; width: 100%;
height: 240px; flex: 1;
min-height: 0;
} }
.tw-footer { .tw-footer {
@@ -619,6 +644,7 @@ watch(activeTab, async tab => {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
flex-shrink: 0;
} }
.tw-footer b { .tw-footer b {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);

View File

@@ -1,293 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchNoticeNotification' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface NoticeRow {
id: string;
title: string;
timeLabel: string;
}
interface NotificationRow {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const notices: NoticeRow[] = [
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' }
];
const notifications: NotificationRow[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前', unread: true },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前', unread: true },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日', unread: false }
];
const unreadCount = computed(() => notifications.filter(n => n.unread).length);
// mock 阶段:交互函数留占位,等后端接口落地后接通
function handleOpenNotification(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 跳对应业务对象详情
// eslint-disable-next-line no-console
console.warn('[notification] open', row.id);
}
function handleMarkRead(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 调标已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', row.id);
}
function handleMarkAllRead() {
// eslint-disable-next-line no-warning-comments
// TODO: 调一键全部已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
</script>
<template>
<WorkbenchModuleCard
title="公告与通知"
icon="mdi:bullhorn-outline"
:badge-count="unreadCount || undefined"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<!-- 1/3公告只读露头扫一眼 -->
<section class="nn-col nn-col--notice">
<header class="nn-h">
<SvgIcon icon="mdi:bullhorn-outline" class="nn-h__icon" />
<span class="nn-h__title">公告</span>
<span class="nn-h__count">{{ notices.length }}</span>
</header>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id" class="nn-notice">
<div class="nn-notice__title">{{ row.title }}</div>
<div class="nn-notice__time">{{ row.timeLabel }}</div>
</li>
</ul>
</section>
<!-- 2/3通知可操作按行跳详情/标已读 -->
<section class="nn-col nn-col--notify">
<header class="nn-h">
<SvgIcon icon="mdi:bell-outline" class="nn-h__icon" />
<span class="nn-h__title">通知</span>
<span v-if="unreadCount > 0" class="nn-h__count is-unread">未读 {{ unreadCount }}</span>
<span v-else class="nn-h__count">{{ notifications.length }}</span>
<ElButton v-if="unreadCount > 0" link size="small" class="nn-h__action" @click="handleMarkAllRead">
全部已读
</ElButton>
</header>
<ul class="nn-list">
<li
v-for="row in notifications"
:key="row.id"
class="nn-notify"
:class="{ 'is-unread': row.unread }"
@click="handleOpenNotification(row)"
>
<span v-if="row.unread" class="nn-notify__dot" />
<span class="nn-notify__title">{{ row.title }}</span>
<span class="nn-notify__time">{{ row.timeLabel }}</span>
<span class="nn-notify__actions">
<ElTooltip v-if="row.unread" content="标为已读" placement="top">
<button class="nn-notify__act" @click.stop="handleMarkRead(row)">
<SvgIcon icon="mdi:check" />
</button>
</ElTooltip>
<ElTooltip content="跳详情" placement="top">
<button class="nn-notify__act" @click.stop="handleOpenNotification(row)">
<SvgIcon icon="mdi:open-in-new" />
</button>
</ElTooltip>
</span>
</li>
</ul>
</section>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
.nn-col {
min-width: 0;
}
.nn-h {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
}
.nn-h__icon {
color: var(--el-color-primary);
font-size: 14px;
}
.nn-h__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.nn-h__count {
padding: 1px 7px;
border-radius: 999px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 1.5;
}
.nn-h__count.is-unread {
background: var(--el-color-danger);
color: #fff;
font-weight: 600;
}
.nn-h__action {
margin-left: auto;
font-size: 11px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.nn-list::-webkit-scrollbar {
width: 6px;
}
.nn-list::-webkit-scrollbar-thumb {
background: var(--el-fill-color-darker);
border-radius: 3px;
}
.nn-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
/* 公告行:纯阅读 + 标题 2 行 clamp */
.nn-notice {
padding: 7px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.nn-notice:last-child {
border-bottom: none;
}
.nn-notice__title {
font-size: 12.5px;
line-height: 1.5;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.nn-notice__time {
margin-top: 3px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 通知行:可操作 + hover 浮出动作按钮 */
.nn-notify {
display: grid;
grid-template-columns: 8px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 8px;
margin: 0 -8px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 120ms;
}
.nn-notify + .nn-notify {
border-top: 1px dashed var(--el-border-color-lighter);
}
.nn-notify:hover {
background: var(--el-fill-color-light);
}
.nn-notify__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary);
}
.nn-notify:not(.is-unread) .nn-notify__dot {
background: transparent;
}
.nn-notify__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--el-text-color-regular);
}
.nn-notify.is-unread .nn-notify__title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.nn-notify__time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
white-space: nowrap;
}
.nn-notify__actions {
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 120ms;
}
.nn-notify:hover .nn-notify__actions {
opacity: 1;
}
.nn-notify__act {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 13px;
transition: background-color 120ms;
}
.nn-notify__act:hover {
background: var(--el-fill-color);
color: var(--el-color-primary);
}
</style>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProductSnapshot' }); defineOptions({ name: 'WorkbenchProductSnapshot' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
interface ProductOption { interface ProductOption {
id: string; id: string;
@@ -69,12 +71,12 @@ function onChange(id: string) {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="产品深度快照" title="产品深度快照"
icon="mdi:image-area-close" icon="mdi:image-area-close"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="ps-head"> <div class="ps-head">
<span class="ps-pin-label">当前产品</span> <span class="ps-pin-label">当前产品</span>

View File

@@ -1,22 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { fetchGetMyOwnedProjectPage, fetchGetMyParticipatedProjectPage } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchOwnedProjectItems, buildWorkbenchProjectItems } from '../homepage'; import {
import { workbenchOwnedProjectMock, workbenchProjectMock } from '../mock'; type WorkbenchOwnedProjectView,
type WorkbenchParticipatedProjectView,
buildWorkbenchOwnedProjects,
buildWorkbenchParticipatedProjects
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' }); defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ defineEmits<{
(e: 'hide'): void; (e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>(); }>();
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();
@@ -25,10 +29,26 @@ type ProjectViewKey = 'participated' | 'owned';
const activeView = ref<ProjectViewKey>('participated'); const activeView = ref<ProjectViewKey>('participated');
const participatedItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock)); const participatedItems = ref<WorkbenchParticipatedProjectView[]>([]);
const ownedItems = computed(() => buildWorkbenchOwnedProjectItems(workbenchOwnedProjectMock)); const ownedItems = ref<WorkbenchOwnedProjectView[]>([]);
const currentOwnedId = ref<string>(ownedItems.value[0]?.id ?? ''); const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉全部;列表已由后端按"进行中 + 创建时间升序"过滤排序
const [participated, owned] = await Promise.all([
fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 }),
fetchGetMyOwnedProjectPage({ pageNo: 1, pageSize: -1 })
]);
if (!participated.error) {
participatedItems.value = buildWorkbenchParticipatedProjects(participated.data?.list ?? []);
}
if (!owned.error) {
ownedItems.value = buildWorkbenchOwnedProjects(owned.data?.list ?? []);
}
});
onMounted(refresh);
const currentOwnedId = ref<string>('');
watch(ownedItems, list => { watch(ownedItems, list => {
if (!list.find(item => item.id === currentOwnedId.value)) { if (!list.find(item => item.id === currentOwnedId.value)) {
currentOwnedId.value = list[0]?.id ?? ''; currentOwnedId.value = list[0]?.id ?? '';
@@ -36,6 +56,24 @@ watch(ownedItems, list => {
}); });
const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null); const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null);
// 成员负载:柱长按组内最高任务数归一(相对负载),颜色按绝对任务数分档(与团队负载 6/4 阈值一致)
function resolveMemberLoadLevel(activeTaskCount: number) {
if (activeTaskCount >= 6) return 'over';
if (activeTaskCount >= 4) return 'warn';
return 'ok';
}
const ownedMembersView = computed(() => {
const members = currentOwned.value?.members ?? [];
const maxTaskCount = members.reduce((max, member) => Math.max(max, member.activeTaskCount), 0);
return members.map(member => ({
userId: member.userId,
userName: member.userName,
activeTaskCount: member.activeTaskCount,
barPercent: maxTaskCount > 0 ? Math.round((member.activeTaskCount / maxTaskCount) * 100) : 0,
level: resolveMemberLoadLevel(member.activeTaskCount)
}));
});
function handleEnterProjectList() { function handleEnterProjectList() {
routerPushByKey('project_list'); routerPushByKey('project_list');
} }
@@ -43,12 +81,12 @@ function handleEnterProjectList() {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="我的项目" title="我的项目"
icon="mdi:briefcase-outline" icon="mdi:briefcase-outline"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="workbench-project__tabs"> <div class="workbench-project__tabs">
<ElRadioGroup v-model="activeView" size="small"> <ElRadioGroup v-model="activeView" size="small">
@@ -60,7 +98,7 @@ function handleEnterProjectList() {
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" /> <SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton> </ElButton>
</div> </div>
<div class="workbench-project__scroll">
<!-- 我参与的网格视图 --> <!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'"> <template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p> <p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
@@ -69,17 +107,20 @@ function handleEnterProjectList() {
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card"> <article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header"> <div class="workbench-project__card-header">
<div class="workbench-project__card-title-group"> <div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title">{{ item.name }}</h4> <h4 class="workbench-project__card-title" :title="item.name">{{ item.name }}</h4>
<span class="workbench-project__card-code">{{ item.code }}</span> <span v-if="item.code" class="workbench-project__card-code">{{ item.code }}</span>
</div> </div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`"> <span
{{ item.statusLabel }} class="workbench-project__card-status"
:class="`workbench-project__card-status--${item.statusTone}`"
>
{{ item.statusName || '进行中' }}
</span> </span>
</div> </div>
<div class="workbench-project__card-role"> <div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span> <span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong> <strong class="workbench-project__card-role-value">{{ item.myRole || '—' }}</strong>
</div> </div>
<div class="workbench-project__progress"> <div class="workbench-project__progress">
@@ -106,10 +147,6 @@ function handleEnterProjectList() {
</span> </span>
</strong> </strong>
</div> </div>
<div class="workbench-project__footer-block workbench-project__footer-block--right">
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div> </div>
</article> </article>
</div> </div>
@@ -154,28 +191,32 @@ function handleEnterProjectList() {
</div> </div>
</div> </div>
</div> </div>
<div class="ps-sub"> {{ currentOwned.remainingDays }} · 我的角色{{ currentOwned.myRole }}</div> <div v-if="currentOwned.plannedEndDate" class="ps-sub">
计划结束 {{ currentOwned.plannedEndDate }}
<div class="ps-section-title">📌 本周关键节点</div> <template v-if="currentOwned.remainingDays !== null">
<ul class="ps-milestones"> ·
<li v-for="m in currentOwned.milestones" :key="m.id"> {{
<span>{{ m.title }}</span> currentOwned.remainingDays >= 0
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span> ? `${currentOwned.remainingDays}`
</li> : `已逾期 ${-currentOwned.remainingDays}`
</ul> }}
</template>
</div>
<div v-else class="ps-sub">未设置计划结束日期</div>
<div class="ps-section-title">👥 成员负载</div> <div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members"> <ul class="ps-members">
<li v-for="m in currentOwned.members" :key="m.name"> <li v-for="m in ownedMembersView" :key="m.userId">
<span class="ps-member-name">{{ m.name }}</span> <span class="ps-member-name" :title="m.userName || ''">{{ m.userName || '—' }}</span>
<div class="ps-bar"> <div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" /> <div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.barPercent}%` }" />
</div> </div>
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span> <span class="ps-member-tasks">{{ m.activeTaskCount }}</span>
</li> </li>
</ul> </ul>
</template> </template>
</template> </template>
</div>
</WorkbenchModuleCard> </WorkbenchModuleCard>
</template> </template>
@@ -186,6 +227,13 @@ function handleEnterProjectList() {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
flex-shrink: 0;
}
.workbench-project__scroll {
flex: 1;
min-height: 0;
overflow: auto;
} }
.workbench-project__desc { .workbench-project__desc {
@@ -202,7 +250,8 @@ function handleEnterProjectList() {
.workbench-project__grid { .workbench-project__grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); /* 按容器宽度自适应列数而非视口minmax 180 让 w7≈588px 容器排 3 列auto-fit 平分不留白 */
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; gap: 16px;
} }
@@ -239,6 +288,11 @@ function handleEnterProjectList() {
.workbench-project__card-title { .workbench-project__card-title {
margin: 0; margin: 0;
/* 标题最长等效 10 个汉字宽度10em≈160px超出省略号hover 看完整名 */
max-width: 10em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(15 23 42 / 98%); color: rgb(15 23 42 / 98%);
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
@@ -356,10 +410,6 @@ function handleEnterProjectList() {
gap: 4px; gap: 4px;
} }
.workbench-project__footer-block--right {
align-items: flex-end;
}
.workbench-project__footer-label { .workbench-project__footer-label {
color: rgb(100 116 139 / 92%); color: rgb(100 116 139 / 92%);
font-size: 12px; font-size: 12px;
@@ -377,18 +427,6 @@ function handleEnterProjectList() {
font-weight: 600; font-weight: 600;
} }
@media (width <= 1280px) {
.workbench-project__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-project__grid {
grid-template-columns: 1fr;
}
}
/* ===== 我负责的:单对象深度详情样式 ===== */ /* ===== 我负责的:单对象深度详情样式 ===== */
.ps-head { .ps-head {
display: flex; display: flex;
@@ -472,32 +510,11 @@ function handleEnterProjectList() {
font-weight: 600; font-weight: 600;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.ps-milestones,
.ps-members { .ps-members {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.ps-milestones li {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.ps-milestones li:last-child {
border-bottom: none;
}
.ps-time {
font-size: 12px;
font-weight: 600;
}
.ps-time.tone-amber {
color: var(--el-color-warning);
}
.ps-time.tone-slate {
color: var(--el-text-color-secondary);
}
.ps-members li { .ps-members li {
display: grid; display: grid;
grid-template-columns: 60px 1fr 30px; grid-template-columns: 60px 1fr 30px;
@@ -506,6 +523,12 @@ function handleEnterProjectList() {
padding: 4px 0; padding: 4px 0;
font-size: 12px; font-size: 12px;
} }
.ps-member-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
}
.ps-bar { .ps-bar {
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
@@ -514,6 +537,7 @@ function handleEnterProjectList() {
} }
.ps-bar-inner { .ps-bar-inner {
height: 100%; height: 100%;
transition: width 240ms ease;
} }
.ps-bar-inner.is-ok { .ps-bar-inner.is-ok {
background: var(--el-color-success); background: var(--el-color-success);
@@ -524,7 +548,7 @@ function handleEnterProjectList() {
.ps-bar-inner.is-over { .ps-bar-inner.is-over {
background: var(--el-color-danger); background: var(--el-color-danger);
} }
.ps-member-load { .ps-member-tasks {
text-align: right; text-align: right;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 11px; font-size: 11px;

View File

@@ -2,15 +2,17 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { buildWorkbenchProjectHealthCards } from '../homepage'; import { buildWorkbenchProjectHealthCards } from '../homepage';
import { workbenchProjectHealthMock } from '../mock'; import { workbenchProjectHealthMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock)); const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
@@ -32,13 +34,13 @@ const productCards: ProductHealth[] = [
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="产品 / 项目健康度" title="产品 / 项目健康度"
icon="mdi:heart-pulse" icon="mdi:heart-pulse"
:badge-count="projectCards.length + productCards.length" :badge-count="projectCards.length + productCards.length"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="demo-banner"> <div class="demo-banner">
<SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" /> <SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" />

View File

@@ -78,6 +78,7 @@ function handleConfirm() {
direction="rtl" direction="rtl"
size="380px" size="380px"
title="选择快捷入口菜单" title="选择快捷入口菜单"
append-to-body
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<template #default> <template #default>

View File

@@ -5,28 +5,28 @@ import { objectContextDomainConfigs } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useWorkbenchStore } from '@/store/modules/workbench'; import { useWorkbenchStore } from '@/store/modules/workbench';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
import WorkbenchShortcutPicker from './workbench-shortcut-picker.vue'; import WorkbenchShortcutPicker from './workbench-shortcut-picker.vue';
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
editing: false, editing: false
collapsed: false
}); });
defineEmits<{ defineEmits<{
(e: 'hide'): void; (e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>(); }>();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const workbench = useWorkbenchStore(); const workbench = useWorkbenchStore();
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh();
interface FlatMenu { interface FlatMenu {
key: string; key: string;
label: string; label: string;
@@ -78,22 +78,26 @@ function handleClick(key: string) {
function handleConfirm(keys: string[]) { function handleConfirm(keys: string[]) {
workbench.updateModuleSettings('shortcut', { menuKeys: keys }); workbench.updateModuleSettings('shortcut', { menuKeys: keys });
} }
function handleRemove(key: string) {
workbench.updateModuleSettings('shortcut', { menuKeys: selectedKeys.value.filter(k => k !== key) });
}
</script> </script>
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="快捷入口" title="快捷入口"
icon="mdi:rocket-launch-outline" icon="mdi:rocket-launch-outline"
:badge-count="selected.length || undefined" :badge-count="selected.length || undefined"
:editing="editing" :editing="editing"
:collapsed="collapsed"
has-settings has-settings
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@open-settings="openPicker" @open-settings="openPicker"
@refresh="refresh"
> >
<div v-if="selected.length === 0" class="shortcut-empty"> <div v-if="selected.length === 0" class="shortcut-empty">
<ElEmpty description="还未选择菜单" :image-size="60"> <ElEmpty description="还未选择菜单" :image-size="48">
<ElButton type="primary" size="small" @click="openPicker">+ 选择菜单</ElButton> <ElButton type="primary" size="small" @click="openPicker">+ 选择菜单</ElButton>
</ElEmpty> </ElEmpty>
</div> </div>
@@ -104,6 +108,9 @@ function handleConfirm(keys: string[]) {
</ElIcon> </ElIcon>
<SvgIcon v-else icon="mdi:link-variant" class="shortcut-item__icon" /> <SvgIcon v-else icon="mdi:link-variant" class="shortcut-item__icon" />
<span class="shortcut-item__label">{{ item.label }}</span> <span class="shortcut-item__label">{{ item.label }}</span>
<span class="shortcut-item__remove" title="移除此快捷入口" @click.stop="handleRemove(item.key)">
<SvgIcon icon="mdi:close" />
</span>
</button> </button>
<button class="shortcut-item shortcut-item--add" title="添加快捷入口" @click="openPicker"> <button class="shortcut-item shortcut-item--add" title="添加快捷入口" @click="openPicker">
<SvgIcon icon="mdi:plus" /> <SvgIcon icon="mdi:plus" />
@@ -120,9 +127,14 @@ function handleConfirm(keys: string[]) {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px; gap: 10px;
flex: 1;
min-height: 0;
overflow: auto;
align-content: start;
} }
.shortcut-item { .shortcut-item {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -137,6 +149,34 @@ function handleConfirm(keys: string[]) {
transition: all 120ms; transition: all 120ms;
} }
.shortcut-item__remove {
position: absolute;
top: 4px;
right: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
color: var(--el-text-color-placeholder);
font-size: 12px;
opacity: 0;
transition:
opacity 120ms,
color 120ms,
background-color 120ms;
}
.shortcut-item:hover .shortcut-item__remove {
opacity: 1;
}
.shortcut-item__remove:hover {
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.shortcut-item__icon { .shortcut-item__icon {
font-size: 20px; font-size: 20px;
color: var(--el-color-primary); color: var(--el-color-primary);
@@ -169,6 +209,15 @@ function handleConfirm(keys: string[]) {
} }
.shortcut-empty { .shortcut-empty {
padding: 20px 0; flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* 压缩 ElEmpty 默认大 padding空态在最小高度下也不溢出 */
.shortcut-empty :deep(.el-empty) {
padding: 12px 0;
} }
</style> </style>

View File

@@ -3,16 +3,18 @@ import { computed } from 'vue';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors'; import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import { type WorkbenchTeamLoadLevel, buildWorkbenchTeamLoadView } from '../homepage'; import { type WorkbenchTeamLoadLevel, buildWorkbenchTeamLoadView } from '../homepage';
import { workbenchTeamLoadMock } from '../mock'; import { workbenchTeamLoadMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamLoad' }); defineOptions({ name: 'WorkbenchTeamLoad' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>(); defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
const view = computed(() => buildWorkbenchTeamLoadView(workbenchTeamLoadMock)); const view = computed(() => buildWorkbenchTeamLoadView(workbenchTeamLoadMock));
@@ -31,12 +33,12 @@ function urgentTooltip(dueSoon: number, overdue: number) {
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="团队负载" title="团队负载"
icon="mdi:scale-balance" icon="mdi:scale-balance"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="tl-kpis"> <div class="tl-kpis">
<div class="tl-kpi"> <div class="tl-kpi">
@@ -150,8 +152,9 @@ function urgentTooltip(dueSoon: number, overdue: number) {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
max-height: 240px; flex: 1;
overflow-y: auto; min-height: 0;
overflow: auto;
} }
.tl-list::-webkit-scrollbar { .tl-list::-webkit-scrollbar {
width: 6px; width: 6px;

View File

@@ -22,6 +22,7 @@ import {
sortWorkbenchTodoItemsByPriority sortWorkbenchTodoItemsByPriority
} from '../homepage'; } from '../homepage';
import { workbenchTodoMock } from '../mock'; import { workbenchTodoMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue'; import WorkbenchModuleCard from './workbench-module-card.vue';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline'; import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline'; import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
@@ -36,18 +37,18 @@ defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props { interface Props {
editing?: boolean; editing?: boolean;
collapsed?: boolean;
} }
withDefaults(defineProps<Props>(), { editing: false, collapsed: false }); withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ defineEmits<{
(e: 'hide'): void; (e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>(); }>();
const { routerPushByKey } = useRouterPush(); const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh();
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const activeTab = ref<WorkbenchTodoMainTab>('all'); const activeTab = ref<WorkbenchTodoMainTab>('all');
@@ -333,12 +334,12 @@ onMounted(loadOvertimeApprovalItems);
<template> <template>
<WorkbenchModuleCard <WorkbenchModuleCard
v-loading="loading"
title="我的待办" title="我的待办"
icon="mdi:clipboard-text-clock-outline" icon="mdi:clipboard-text-clock-outline"
:editing="editing" :editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')" @hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')" @refresh="refresh"
> >
<div class="workbench-todo__tabs"> <div class="workbench-todo__tabs">
<div class="workbench-todo__tabs-group"> <div class="workbench-todo__tabs-group">
@@ -493,10 +494,12 @@ onMounted(loadOvertimeApprovalItems);
/> />
</div> </div>
<!-- append-to-body脱离 grid item transform 容器弹窗才能正常全屏居中 -->
<PersonalItemOperateDialog <PersonalItemOperateDialog
v-model:visible="addDialogVisible" v-model:visible="addDialogVisible"
operate-type="add" operate-type="add"
:row-data="null" :row-data="null"
append-to-body
@submitted="handleAddSubmitted" @submitted="handleAddSubmitted"
/> />
@@ -691,9 +694,11 @@ onMounted(loadOvertimeApprovalItems);
} }
.workbench-todo__content { .workbench-todo__content {
min-height: 400px; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto;
} }
.workbench-todo__content :deep(.el-empty) { .workbench-todo__content :deep(.el-empty) {