feat(projects): 工作台部分组件调成真实数据

This commit is contained in:
2026-06-04 11:26:51 +08:00
parent acef4418d8
commit 39458386ae
33 changed files with 1033 additions and 1169 deletions

View File

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

49
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
element-plus:
specifier: ^2.11.1
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:
specifier: 3.12.1
version: 3.12.1
@@ -882,6 +885,9 @@ packages:
peerDependencies:
vue: '>=3'
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@intlify/core-base@11.1.11':
resolution: {integrity: sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ==}
engines: {node: '>= 16'}
@@ -921,6 +927,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
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':
resolution: {integrity: sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ==}
peerDependencies:
@@ -1817,6 +1826,14 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
peerDependencies:
@@ -3493,6 +3510,11 @@ packages:
graphlib@2.1.8:
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:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
@@ -3629,6 +3651,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -6226,6 +6251,8 @@ snapshots:
'@iconify/types': 2.0.0
vue: 3.5.20(typescript@5.8.3)
'@interactjs/types@1.10.27': {}
'@intlify/core-base@11.1.11':
dependencies:
'@intlify/message-compiler': 11.1.11
@@ -6273,6 +6300,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@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))':
dependencies:
workerize-loader: 2.0.2(webpack@5.105.4)
@@ -7082,6 +7111,15 @@ snapshots:
'@uppy/utils': 4.1.3
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)':
dependencies:
'@visactor/vchart': 2.0.4
@@ -9179,6 +9217,13 @@ snapshots:
dependencies:
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:
dependencies:
duplexer: 0.1.2
@@ -9295,6 +9340,10 @@ snapshots:
inherits@2.0.4: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0

View File

@@ -40,6 +40,42 @@ export type ProjectExecutionResponse = Omit<
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'> & {
id: 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 {
return {
...response,

View File

@@ -10,6 +10,9 @@ import {
import {
type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse,
type MyExecutionResponse,
type MyOwnedProjectResponse,
type MyParticipatedProjectResponse,
type ProjectExecutionResponse,
type ProjectLocalDateValue,
type ProjectMemberResponse,
@@ -20,6 +23,9 @@ import {
getProjectLifecycleActions,
normalizeExecutionAssignee,
normalizeExecutionAssigneeLog,
normalizeMyExecution,
normalizeMyOwnedProject,
normalizeMyParticipatedProject,
normalizeProjectExecution,
normalizeProjectLocalDate,
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(
projectId: string,

View File

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

View File

@@ -1,6 +1,4 @@
import { type Ref, computed, markRaw } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { canReportTaskWorklog } from '../shared';
import { markRaw } from 'vue';
import { useTaskPermissions } from './use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-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 映射。
*
* dataRef 用于填报按钮的"叶子"判定canReportTaskWorklog 需要全量行集合)。
*/
export function useTaskActions(dataRef: Ref<Api.Project.ProjectTask[]>, emits: TaskActionEmits) {
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
export function useTaskActions(emits: TaskActionEmits) {
const { canEditTask, canDeleteTask } = useTaskPermissions();
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定
if (hasReportWorklogPermission() && canReportTaskWorklog(row, dataRef.value, currentUserId.value)) {
actions.push({
key: 'report',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emits.report(row)
});
}
// 工作日志:行操作入口始终显示——查看人人可看;新增/编辑由弹层内 canSubmit 按身份状态控制
actions.push({
key: 'report',
tooltip: '工作日志',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emits.report(row)
});
if (canEditTask(row)) {
actions.push({

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { computed } from 'vue';
import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { useAuthStore } from '@/store/modules/auth';
@@ -50,7 +50,7 @@ function getRoleLabel(row: Api.Project.ProjectTask): { label: string; type: Role
return { label: '旁观', type: undefined };
}
const { createActions } = useTaskActions(toRef(props, 'data'), {
const { createActions } = useTaskActions({
edit: row => emit('edit', row),
report: row => emit('report', row),
remove: row => emit('delete', row),

View File

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

View File

@@ -473,7 +473,7 @@ watch(
<template>
<div class="task-worklog-panel">
<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>
<ElTable
@@ -484,7 +484,7 @@ watch(
empty-text="暂无工作日志"
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">
<template #default="{ row }">
<ElTag

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,9 @@ import dayjs from 'dayjs';
import type {
WorkbenchActivityItemSource,
WorkbenchKpiSource,
WorkbenchMyExecutionItemSource,
WorkbenchMyWeekWorklogSource,
WorkbenchOwnedProjectItemSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamLoadSource,
WorkbenchTeamWorklogSource,
WorkbenchTodoItemSource
@@ -208,245 +205,6 @@ export const workbenchActivityMock = [
}
] 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 previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD');

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { inject } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' });
@@ -12,31 +12,25 @@ interface Props {
icon?: string;
badgeCount?: number;
editing?: boolean;
collapsed?: boolean;
hasSettings?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
icon: undefined,
badgeCount: undefined,
editing: false,
collapsed: false,
hasSettings: false
});
const emit = defineEmits<{
(e: 'toggle-collapse'): void;
(e: 'hide'): void;
(e: 'open-settings'): void;
(e: 'refresh'): void;
(e: 'navigate'): void;
}>();
const showBody = computed(() => !props.collapsed);
</script>
<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">
<span v-if="editing" class="module-drag-handle" title="拖动调整位置">
<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')">
<SvgIcon icon="mdi:cog-outline" />
</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')">
<SvgIcon icon="mdi:refresh" />
</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()">
<SvgIcon icon="mdi:view-dashboard-edit-outline" />
</ElButton>
@@ -73,7 +55,7 @@ const showBody = computed(() => !props.collapsed);
</div>
</header>
<div v-show="showBody" class="module-card__body">
<div class="module-card__body">
<slot />
</div>
</section>
@@ -84,7 +66,7 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
min-height: 180px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -98,10 +80,6 @@ const showBody = computed(() => !props.collapsed);
border-color: var(--el-color-primary-light-5);
}
.module-card.is-collapsed {
min-height: auto;
}
.module-card__head {
display: flex;
align-items: center;
@@ -111,10 +89,6 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-fill-color-blank);
}
.module-card.is-collapsed .module-card__head {
border-bottom: none;
}
.module-drag-handle {
cursor: grab;
color: var(--el-text-color-secondary);
@@ -153,7 +127,10 @@ const showBody = computed(() => !props.collapsed);
.module-card__body {
flex: 1;
min-height: 0;
padding: 14px;
overflow: auto;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
import type { WorkbenchModuleCategory, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
interface Props {
modelValue: boolean;
@@ -13,7 +9,7 @@ interface Props {
const props = defineProps<Props>();
const emit = defineEmits<{
(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)"
>
<template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p>
<p class="hint">点击下方模块加入工作台落到网格底部可在编辑态拖动调整</p>
<div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div>
<div v-else class="library">
<section v-for="group in groups" :key="group.key" class="library-group">
<h4 class="library-group__title">{{ group.label }}</h4>
<ul class="library-group__list">
<li
v-for="meta in group.items"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<li v-for="meta in group.items" :key="meta.key" class="library-item" @click="emit('add-module', meta.key)">
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
</li>

View File

@@ -1,30 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyExecutionPage } from '@/service/api';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared';
import { type WorkbenchMyExecutionItem, buildWorkbenchMyExecutionItems } from '../homepage';
import { workbenchMyExecutionMock } from '../mock';
import { buildWorkbenchMyExecutionItems } from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' });
type MyExecutionItem = Api.Project.MyExecutionItem;
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
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 map = new Map<string, { 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: MyExecutionItem[] }>();
items.value.forEach(item => {
if (!map.has(item.projectId)) {
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);
});
// 手风琴:单开,默认展开第一个项目(执行最多);展开项消失时回退到第一个
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) {
router.push({
path: '/project/project/execution',
@@ -49,7 +80,7 @@ function goProjectExecutionPool(projectId: string) {
});
}
function goRequirementDetail(item: WorkbenchMyExecutionItem) {
function goRequirementDetail(item: MyExecutionItem) {
if (!item.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
@@ -67,27 +98,43 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
icon="mdi:flag-checkered"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div v-if="items.length" class="exec-groups">
<section v-for="group in groups" :key="group.projectId" class="exec-group">
<header class="exec-group__head">
<div v-if="items.length" v-loading="loading" class="exec-groups">
<section
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" />
<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__name" :title="group.projectName">{{ group.projectName }}</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>
<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">
<div class="exec-head">
<span class="exec-name" :title="item.executionName">{{ item.executionName }}</span>
@@ -122,6 +169,7 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</div>
<div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row">
<SvgIcon icon="mdi:link-variant" class="exec-meta__icon" />
<span class="exec-meta__label">需求</span>
<span
class="exec-meta__text exec-meta__link"
role="button"
@@ -139,28 +187,66 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</ul>
</section>
</div>
<ElEmpty v-else description="暂无进行中的执行" :image-size="60" />
<div v-else v-loading="loading" class="exec-empty">
<ElEmpty description="暂无进行中的执行" :image-size="60" />
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.exec-groups {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
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 {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 0 2px 6px;
border-bottom: 1px dashed var(--el-border-color-lighter);
padding: 8px 10px;
border-radius: 6px;
/* 常驻分组底色,明显区别于下方执行项卡片 */
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 {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
font-size: 15px;
/* 项目 icon 用主色,与项目管理业务域图标一致且更醒目 */
color: var(--el-color-primary);
}
.exec-group__name {
flex: 1;
@@ -168,17 +254,16 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
font-size: 13.5px;
font-weight: 600;
color: var(--el-text-color-regular);
color: var(--el-text-color-primary);
letter-spacing: 0.01em;
cursor: pointer;
transition: color 0.16s ease;
}
.exec-group__name:hover,
.exec-group__name:focus-visible {
.exec-group.is-open .exec-group__name {
color: var(--el-color-primary);
outline: none;
}
.exec-group__go {
flex-shrink: 0;
}
.exec-group__count {
flex-shrink: 0;
@@ -195,8 +280,8 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
}
.exec-list {
list-style: none;
margin: 0;
padding: 0;
margin: 4px 0 2px;
padding: 0 2px 0 22px;
display: flex;
flex-direction: column;
gap: 8px;
@@ -252,6 +337,10 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.exec-meta__label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.exec-meta__text {
overflow: hidden;
white-space: nowrap;

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,26 @@
<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 { buildWorkbenchOwnedProjectItems, buildWorkbenchProjectItems } from '../homepage';
import { workbenchOwnedProjectMock, workbenchProjectMock } from '../mock';
import {
type WorkbenchOwnedProjectView,
type WorkbenchParticipatedProjectView,
buildWorkbenchOwnedProjects,
buildWorkbenchParticipatedProjects
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
@@ -25,10 +29,26 @@ type ProjectViewKey = 'participated' | 'owned';
const activeView = ref<ProjectViewKey>('participated');
const participatedItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
const ownedItems = computed(() => buildWorkbenchOwnedProjectItems(workbenchOwnedProjectMock));
const participatedItems = ref<WorkbenchParticipatedProjectView[]>([]);
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 => {
if (!list.find(item => item.id === currentOwnedId.value)) {
currentOwnedId.value = list[0]?.id ?? '';
@@ -36,6 +56,24 @@ watch(ownedItems, list => {
});
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() {
routerPushByKey('project_list');
}
@@ -43,12 +81,12 @@ function handleEnterProjectList() {
<template>
<WorkbenchModuleCard
v-loading="loading"
title="我的项目"
icon="mdi:briefcase-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="workbench-project__tabs">
<ElRadioGroup v-model="activeView" size="small">
@@ -60,122 +98,125 @@ function handleEnterProjectList() {
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
<div class="workbench-project__scroll">
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<div v-if="participatedItems.length" class="workbench-project__grid">
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title">{{ item.name }}</h4>
<span class="workbench-project__card-code">{{ item.code }}</span>
<div v-if="participatedItems.length" class="workbench-project__grid">
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title" :title="item.name">{{ item.name }}</h4>
<span v-if="item.code" class="workbench-project__card-code">{{ item.code }}</span>
</div>
<span
class="workbench-project__card-status"
:class="`workbench-project__card-status--${item.statusTone}`"
>
{{ item.statusName || '进行中' }}
</span>
</div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
</div>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong>
</div>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole || '—' }}</strong>
</div>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<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 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-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
<div class="workbench-project__footer">
<div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
</div>
</div>
<div class="workbench-project__footer-block workbench-project__footer-block--right">
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div>
</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>
</article>
</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 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>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</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>
</template>
@@ -186,6 +227,13 @@ function handleEnterProjectList() {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.workbench-project__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.workbench-project__desc {
@@ -202,7 +250,8 @@ function handleEnterProjectList() {
.workbench-project__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;
}
@@ -239,6 +288,11 @@ function handleEnterProjectList() {
.workbench-project__card-title {
margin: 0;
/* 标题最长等效 10 个汉字宽度10em≈160px超出省略号hover 看完整名 */
max-width: 10em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
@@ -356,10 +410,6 @@ function handleEnterProjectList() {
gap: 4px;
}
.workbench-project__footer-block--right {
align-items: flex-end;
}
.workbench-project__footer-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
@@ -377,18 +427,6 @@ function handleEnterProjectList() {
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 {
display: flex;
@@ -472,32 +510,11 @@ function handleEnterProjectList() {
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-milestones,
.ps-members {
list-style: none;
margin: 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 {
display: grid;
grid-template-columns: 60px 1fr 30px;
@@ -506,6 +523,12 @@ function handleEnterProjectList() {
padding: 4px 0;
font-size: 12px;
}
.ps-member-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
}
.ps-bar {
height: 6px;
border-radius: 3px;
@@ -514,6 +537,7 @@ function handleEnterProjectList() {
}
.ps-bar-inner {
height: 100%;
transition: width 240ms ease;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
@@ -524,7 +548,7 @@ function handleEnterProjectList() {
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
.ps-member-tasks {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;

View File

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

View File

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

View File

@@ -5,28 +5,28 @@ import { objectContextDomainConfigs } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { useRouterPush } from '@/hooks/common/router';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
import WorkbenchShortcutPicker from './workbench-shortcut-picker.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), {
editing: false,
collapsed: false
editing: false
});
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const routeStore = useRouteStore();
const workbench = useWorkbenchStore();
const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh();
interface FlatMenu {
key: string;
label: string;
@@ -78,22 +78,26 @@ function handleClick(key: string) {
function handleConfirm(keys: string[]) {
workbench.updateModuleSettings('shortcut', { menuKeys: keys });
}
function handleRemove(key: string) {
workbench.updateModuleSettings('shortcut', { menuKeys: selectedKeys.value.filter(k => k !== key) });
}
</script>
<template>
<WorkbenchModuleCard
v-loading="loading"
title="快捷入口"
icon="mdi:rocket-launch-outline"
:badge-count="selected.length || undefined"
:editing="editing"
:collapsed="collapsed"
has-settings
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@open-settings="openPicker"
@refresh="refresh"
>
<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>
</ElEmpty>
</div>
@@ -104,6 +108,9 @@ function handleConfirm(keys: string[]) {
</ElIcon>
<SvgIcon v-else icon="mdi:link-variant" class="shortcut-item__icon" />
<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 class="shortcut-item shortcut-item--add" title="添加快捷入口" @click="openPicker">
<SvgIcon icon="mdi:plus" />
@@ -120,9 +127,14 @@ function handleConfirm(keys: string[]) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
flex: 1;
min-height: 0;
overflow: auto;
align-content: start;
}
.shortcut-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -137,6 +149,34 @@ function handleConfirm(keys: string[]) {
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 {
font-size: 20px;
color: var(--el-color-primary);
@@ -169,6 +209,15 @@ function handleConfirm(keys: string[]) {
}
.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>

View File

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

View File

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