feat(projects): 工作台部分组件调成真实数据
This commit is contained in:
@@ -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
49
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
101
src/typings/api/project.d.ts
vendored
101
src/typings/api/project.d.ts
vendored
@@ -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;
|
||||||
|
/** 优先级字典 value(rdms_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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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';
|
||||||
@@ -50,7 +50,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),
|
||||||
|
|||||||
@@ -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_start)OR active OR completed(completed 后填报不回写进度,由 form-dialog 内进度只读兜底)
|
// - 状态:pending(首次填触发 auto_start)OR active OR completed(completed 后填报不回写进度,由 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
|
||||||
};
|
};
|
||||||
// 协办人视角:只看自己的 worklog;owner 视角:全量加载
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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=7):myTodo(y=0 h=25) → myWeekWorklog(y=25 h=22)
|
||||||
// right: shortcut(1) → myProject(2) → myWeekWorklog(3) → teamLoad(4)
|
// 右列(x=7 w=5):shortcut(y=0 h=11) → myProject(y=11 h=17) → myExecution(y=28 h=19)
|
||||||
// hidden: projectHealth, noticeNotification, productSnapshot
|
// 底部满宽(x=0 w=12):teamLoad(y=47 h=16)
|
||||||
// (noticeNotification 隐藏原因:公告搬到 banner、通知归全局头部铃铛)
|
// hidden(x/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
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
30
src/views/workbench/composables/use-workbench-refresh.ts
Normal file
30
src/views/workbench/composables/use-workbench-refresh.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
|
||||||
if (m.defaultVisible) {
|
// 注册中心存在但布局未含的 key:可见的落网格底部,其余进 hidden
|
||||||
const target = columns.find(c => c.id === m.defaultColumn) ?? columns[0];
|
for (const m of modules.filter(item => !appearKeys.has(item.key))) {
|
||||||
target.modules.push(m.key);
|
if (m.defaultVisible) {
|
||||||
} else {
|
grid.push({
|
||||||
hidden.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 {
|
||||||
|
hidden.push(m.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...layout, columns, hidden, collapsed };
|
return { ...layout, grid, hidden };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
progress: clampPercent(item.progress),
|
statusName: item.statusName,
|
||||||
lastActiveLabel: formatRelative(item.lastActiveTime)
|
statusTone: resolveParticipatedProjectTone(item.statusCode),
|
||||||
} satisfies WorkbenchProjectItem;
|
myRole: item.myRole,
|
||||||
});
|
progress: clampPercent(item.progress),
|
||||||
|
myTaskCount: item.myTaskCount,
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
>
|
||||||
</section>
|
<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="() => {}"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
:key="group.projectId"
|
||||||
|
class="exec-group"
|
||||||
|
:class="{ 'is-open': expandedProjectId === group.projectId }"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="exec-group__head"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="expandedProjectId === group.projectId"
|
||||||
|
@click="toggleProject(group.projectId)"
|
||||||
|
@keydown.enter.prevent="toggleProject(group.projectId)"
|
||||||
|
>
|
||||||
|
<SvgIcon
|
||||||
|
icon="mdi:chevron-right"
|
||||||
|
class="exec-group__chevron"
|
||||||
|
:class="{ 'is-open': expandedProjectId === group.projectId }"
|
||||||
|
/>
|
||||||
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
|
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
|
||||||
<span
|
<span class="exec-group__name" :title="group.projectName">{{ group.projectName }}</span>
|
||||||
class="exec-group__name"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
:title="`进入「${group.projectName}」执行池`"
|
|
||||||
@click="goProjectExecutionPool(group.projectId)"
|
|
||||||
@keydown.enter.prevent="goProjectExecutionPool(group.projectId)"
|
|
||||||
>
|
|
||||||
{{ 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,122 +98,125 @@ 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'">
|
||||||
|
<p class="workbench-project__desc">直接看每个项目的当前进度、我的角色与未完成任务</p>
|
||||||
|
|
||||||
<!-- 我参与的:网格视图 -->
|
<div v-if="participatedItems.length" class="workbench-project__grid">
|
||||||
<template v-if="activeView === 'participated'">
|
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
|
||||||
<p class="workbench-project__desc">直接看每个项目的当前进度、我的角色与未完成任务</p>
|
<div class="workbench-project__card-header">
|
||||||
|
<div class="workbench-project__card-title-group">
|
||||||
<div v-if="participatedItems.length" class="workbench-project__grid">
|
<h4 class="workbench-project__card-title" :title="item.name">{{ item.name }}</h4>
|
||||||
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
|
<span v-if="item.code" class="workbench-project__card-code">{{ item.code }}</span>
|
||||||
<div class="workbench-project__card-header">
|
</div>
|
||||||
<div class="workbench-project__card-title-group">
|
<span
|
||||||
<h4 class="workbench-project__card-title">{{ item.name }}</h4>
|
class="workbench-project__card-status"
|
||||||
<span class="workbench-project__card-code">{{ item.code }}</span>
|
:class="`workbench-project__card-status--${item.statusTone}`"
|
||||||
|
>
|
||||||
|
{{ item.statusName || '进行中' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
|
|
||||||
{{ item.statusLabel }}
|
|
||||||
</span>
|
|
||||||
</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">
|
||||||
<div class="workbench-project__progress-header">
|
<div class="workbench-project__progress-header">
|
||||||
<span class="workbench-project__progress-label">进度</span>
|
<span class="workbench-project__progress-label">进度</span>
|
||||||
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
|
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="workbench-project__progress-bar">
|
||||||
|
<div
|
||||||
|
class="workbench-project__progress-bar-inner"
|
||||||
|
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
|
||||||
|
:style="{ width: `${item.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="workbench-project__progress-bar">
|
|
||||||
<div
|
|
||||||
class="workbench-project__progress-bar-inner"
|
|
||||||
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
|
|
||||||
:style="{ width: `${item.progress}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workbench-project__footer">
|
<div class="workbench-project__footer">
|
||||||
<div class="workbench-project__footer-block">
|
<div class="workbench-project__footer-block">
|
||||||
<span class="workbench-project__footer-label">我负责的任务</span>
|
<span class="workbench-project__footer-label">我负责的任务</span>
|
||||||
<strong class="workbench-project__footer-value">
|
<strong class="workbench-project__footer-value">
|
||||||
{{ item.myTaskCount }}
|
{{ item.myTaskCount }}
|
||||||
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
|
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
|
||||||
(待处理 {{ item.myPendingTaskCount }})
|
(待处理 {{ item.myPendingTaskCount }})
|
||||||
</span>
|
</span>
|
||||||
</strong>
|
</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="workbench-project__footer-block workbench-project__footer-block--right">
|
</article>
|
||||||
<span class="workbench-project__footer-label">最近活动</span>
|
|
||||||
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 我负责的:单对象深度详情 -->
|
|
||||||
<template v-else>
|
|
||||||
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="ownedItems.length > 1" class="ps-head">
|
|
||||||
<span class="ps-pin-label">当前项目:</span>
|
|
||||||
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
|
|
||||||
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
|
|
||||||
</ElSelect>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="ps-head ps-head--single">
|
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
|
||||||
<span class="ps-pin-label">当前项目:</span>
|
|
||||||
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ps-overview">
|
|
||||||
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
|
|
||||||
<span>{{ currentOwned.progress }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-kpis">
|
|
||||||
<div class="ps-kpi">
|
|
||||||
<b>{{ currentOwned.executionCount }}</b>
|
|
||||||
<span>执行</span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-kpi">
|
|
||||||
<b>{{ currentOwned.taskCount }}</b>
|
|
||||||
<span>任务</span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-kpi">
|
|
||||||
<b>{{ currentOwned.memberCount }}</b>
|
|
||||||
<span>成员</span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-kpi">
|
|
||||||
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
|
|
||||||
<span>逾期</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ps-sub">剩 {{ currentOwned.remainingDays }} 天 · 我的角色:{{ currentOwned.myRole }}</div>
|
|
||||||
|
|
||||||
<div class="ps-section-title">📌 本周关键节点</div>
|
|
||||||
<ul class="ps-milestones">
|
|
||||||
<li v-for="m in currentOwned.milestones" :key="m.id">
|
|
||||||
<span>{{ m.title }}</span>
|
|
||||||
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="ps-section-title">👥 成员负载</div>
|
|
||||||
<ul class="ps-members">
|
|
||||||
<li v-for="m in currentOwned.members" :key="m.name">
|
|
||||||
<span class="ps-member-name">{{ m.name }}</span>
|
|
||||||
<div class="ps-bar">
|
|
||||||
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
|
||||||
|
<!-- 我负责的:单对象深度详情 -->
|
||||||
|
<template v-else>
|
||||||
|
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="ownedItems.length > 1" class="ps-head">
|
||||||
|
<span class="ps-pin-label">当前项目:</span>
|
||||||
|
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
|
||||||
|
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</ElSelect>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ps-head ps-head--single">
|
||||||
|
<span class="ps-pin-label">当前项目:</span>
|
||||||
|
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ps-overview">
|
||||||
|
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
|
||||||
|
<span>{{ currentOwned.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-kpis">
|
||||||
|
<div class="ps-kpi">
|
||||||
|
<b>{{ currentOwned.executionCount }}</b>
|
||||||
|
<span>执行</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-kpi">
|
||||||
|
<b>{{ currentOwned.taskCount }}</b>
|
||||||
|
<span>任务</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-kpi">
|
||||||
|
<b>{{ currentOwned.memberCount }}</b>
|
||||||
|
<span>成员</span>
|
||||||
|
</div>
|
||||||
|
<div class="ps-kpi">
|
||||||
|
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
|
||||||
|
<span>逾期</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentOwned.plannedEndDate" class="ps-sub">
|
||||||
|
计划结束 {{ currentOwned.plannedEndDate }}
|
||||||
|
<template v-if="currentOwned.remainingDays !== null">
|
||||||
|
·
|
||||||
|
{{
|
||||||
|
currentOwned.remainingDays >= 0
|
||||||
|
? `剩 ${currentOwned.remainingDays} 天`
|
||||||
|
: `已逾期 ${-currentOwned.remainingDays} 天`
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ps-sub">未设置计划结束日期</div>
|
||||||
|
|
||||||
|
<div class="ps-section-title">👥 成员负载</div>
|
||||||
|
<ul class="ps-members">
|
||||||
|
<li v-for="m in ownedMembersView" :key="m.userId">
|
||||||
|
<span class="ps-member-name" :title="m.userName || ''">{{ m.userName || '—' }}</span>
|
||||||
|
<div class="ps-bar">
|
||||||
|
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.barPercent}%` }" />
|
||||||
|
</div>
|
||||||
|
<span class="ps-member-tasks">{{ m.activeTaskCount }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user