refactor(project): 重构项目执行模块组件结构和数据管理

- 移除 execution-list-panel.vue 组件并将功能整合到执行区域
- 新增 execution-section.vue 组件替代原有的列表面板
- 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用
- 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理
- 添加跨执行任务状态统计接口调用和数据处理逻辑
- 重构执行状态筛选和任务创建权限判断逻辑
- 更新执行选择、搜索和重置功能的事件处理方式
- 调整页面布局结构,优化左右分栏的内容组织方式
- 完善执行详情获取和状态操作的业务流程
- 优化执行分配和状态变更的异步处理机制
This commit is contained in:
2026-05-23 14:22:58 +08:00
parent 13b74cfe97
commit e9214137c1
40 changed files with 4432 additions and 1419 deletions

View File

@@ -112,6 +112,8 @@ export type ProjectTaskResponse = Omit<
| 'executionId'
| 'parentTaskId'
| 'ownerId'
| 'executionOwnerId'
| 'parentTaskOwnerId'
| 'availableActions'
| 'plannedStartDate'
| 'plannedEndDate'
@@ -126,8 +128,12 @@ export type ProjectTaskResponse = Omit<
id: StringIdResponse;
projectId: StringIdResponse;
executionId: StringIdResponse;
executionName?: string | null;
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
parentTaskId?: StringIdResponse | null;
ownerId: StringIdResponse;
executionOwnerId?: StringIdResponse | null;
parentTaskOwnerId?: StringIdResponse | null;
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
plannedStartDate?: ProjectLocalDateValue;
plannedEndDate?: ProjectLocalDateValue;
@@ -314,6 +320,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId),
executionName: response.executionName ?? null,
executionStatusCode: response.executionStatusCode ?? null,
parentTaskId: normalizeNullableStringId(response.parentTaskId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
@@ -321,6 +329,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null,
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
statusName: response.statusName ?? null,
terminal: Boolean(response.terminal),
allowEdit: Boolean(response.allowEdit),

View File

@@ -668,6 +668,80 @@ export function fetchChangeProjectTaskStatus(
});
}
// ============= 项目级跨执行任务(不带 executionId 路径段) =============
// 调试文档:所有接口挂在 /project/project/{projectId}/tasks/* 下;通过 involveUserId / ownerId / executionIds 等
// 入参组合表达"我的任务 / 项目全部 / 指定执行"等视角。原有执行级 {eid}/tasks/page 等保留不动。
function getProjectTasksPrefix(projectId: string) {
return `${PROJECT_PREFIX}/${projectId}/tasks`;
}
/** 项目级跨执行任务分页 */
export async function fetchGetProjectTaskPageCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossSearchParams
) {
const result = await request<ProjectTaskPageResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskPageResponse>, data => ({
...data,
list: data.list.map(normalizeProjectTask)
}));
}
/** 项目级跨执行任务状态看板 */
export function fetchGetProjectTaskStatusBoardCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossStatusBoardParams
) {
return request<StatusBoardResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/status-board`,
method: 'get',
params
});
}
/** 项目级跨执行任务看板分页(每列共用同一组 pageNo / pageSize列内固定 plannedEndDate ASC, id DESC */
export async function fetchGetProjectTaskBoardPageCross(
projectId: string,
params?: Api.Project.ProjectTaskCrossBoardPageParams
) {
const result = await request<ProjectTaskBoardPageResponse>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/board-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskBoardPageResponse>, data => ({
items: data.items.map(item => ({
...item,
list: item.list.map(normalizeProjectTask)
}))
}));
}
/**
* 项目级"今日小条"汇总4 个数字 + 服务器日期边界)。
*
* scope=all 必须有 project:task:list-all 权限,否则 403PROJECT_OBJECT_PERMISSION_DENIED
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403UI 应自动切回"我的"。
*/
export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) {
return request<Api.Project.ProjectTaskSummary>({
...safeJsonRequestConfig,
url: `${getProjectTasksPrefix(projectId)}/summary`,
method: 'get',
params
});
}
type TaskWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {

View File

@@ -220,19 +220,23 @@ declare namespace Api {
id: string;
projectId: string;
executionId: string;
/** 所属执行名称;跨执行查询必有,单执行查询可缺省 */
executionName?: string | null;
/** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */
executionStatusCode?: ProjectExecutionStatusCode | null;
parentTaskId: string | null;
/** 所属执行关联的项目需求 ID透传未关联 = null */
projectRequirementId: string | null;
/** 所属执行关联的项目需求名称(透传,未关联 = null */
/** 所属执行关联的项目需求名称(透传,未关联 = null;跨执行查询永远为 null前端不在跨执行视角展示 */
projectRequirementName: string | null;
/** 所属执行关联的项目需求状态编码(透传,未关联 = null */
/** 所属执行关联的项目需求状态编码(同上 */
projectRequirementStatusCode: string | null;
taskTitle: string;
type: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用 */
executionOwnerId: string;
/** 所属执行的负责人 userId按钮可见度公式用;跨执行查询永远为 null按钮判定退化为只看权限码 */
executionOwnerId: string | null;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: string | null;
statusCode: ProjectTaskStatusCode;
@@ -376,6 +380,76 @@ declare namespace Api {
items: ProjectTaskBoardColumn[];
}
/** 截止时间快速选项(跨执行接口专属) */
type ProjectTaskDueRange = 'overdue' | 'today' | 'thisWeek';
/** 跨执行任务排序字段 */
type ProjectTaskCrossSortBy = 'plannedEndDate' | 'priority' | 'updateTime' | 'createTime';
type ProjectTaskCrossSortOrder = 'asc' | 'desc';
/**
* 项目级跨执行任务分页入参(`GET /project/project/{projectId}/tasks/page`)。
*
* - `involveUserId` / `ownerId` 互斥:同传只 `ownerId` 生效(后端 SQL 双重过滤)。
* - `executionIds` 不传 = 项目内全部执行。
* - `executionStatusCodes` 在任务可见性之上叠加"任务所属执行状态 ∈ 白名单"过滤;多值 OR
* 与 `executionIds` 同传时为 AND。详见 `docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html`。
* - 不传 `involveUserId / ownerId` 且无 `project:task:list-all` 权限时,后端静默降级为"自己有身份的范围",不抛 403。
*/
type ProjectTaskCrossSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
executionIds: string[];
/** 任务所属执行的状态白名单(用于左侧执行池按状态 chip 切换时的任务范围过滤) */
executionStatusCodes: ProjectExecutionStatusCode[];
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
statusCodes: ProjectTaskStatusCode[];
/** 优先级字典 value"0"~"3" */
priority: string;
parentTaskId: string;
dueRange: ProjectTaskDueRange;
/** 更新时间范围 [start, end],格式 yyyy-MM-dd HH:mm:ss */
updateTime: string[];
sortBy: ProjectTaskCrossSortBy;
sortOrder: ProjectTaskCrossSortOrder;
}
>;
/** 项目级跨执行任务状态看板入参(与 page 同口径但不含 pageNo/pageSize/statusCodes/sortBy/sortOrder */
type ProjectTaskCrossStatusBoardParams = Omit<
ProjectTaskCrossSearchParams,
'pageNo' | 'pageSize' | 'statusCodes' | 'sortBy' | 'sortOrder'
>;
/** 项目级跨执行任务看板分页入参 */
type ProjectTaskCrossBoardPageParams = Omit<ProjectTaskCrossSearchParams, 'sortBy' | 'sortOrder'>;
/** 项目级"今日小条"汇总入参 */
interface ProjectTaskSummaryParams {
/** 默认 mine不传也走 mineall 必须有 project:task:list-all 权限,否则 403 */
scope?: 'mine' | 'all';
}
/**
* 项目级"今日小条"汇总响应(`GET /project/project/{projectId}/tasks/summary`)。
*
* 数字一致性dueThisWeek 的范围与 page?dueRange=thisWeek 完全一致(本周一~本周日)。
* today / weekStart / weekEnd 直接展示,不要前端再算"今天/本周一"(服务器时区为 Asia/Shanghai
*/
interface ProjectTaskSummary {
overdue: number;
dueToday: number;
dueThisWeek: number;
doneThisWeek: number;
today: string;
weekStart: string;
weekEnd: string;
}
interface SaveProjectTaskParams {
parentTaskId: string | null;
taskTitle: string;

View File

@@ -36,7 +36,7 @@ const STATUS_ACTION_ICON_MAP: Record<string, object> = {
cancel: markRaw(IconMdiCloseCircleOutline)
};
// 状态推进按钮 type 映射(对齐执行 execution-list-panel.vue 同源语义):
// 状态推进按钮 type 映射(对齐执行 execution-section.vue 同源语义):
// cancel 破坏性=红pause 中断=橙complete 完结=绿resume 主动作=蓝
const STATUS_ACTION_TYPE_MAP: Record<string, TaskAction['type']> = {
cancel: 'danger',

View File

@@ -1,5 +1,5 @@
import { type Ref, ref, watch } from 'vue';
import { fetchGetProjectTaskBoardPage } from '@/service/api/project';
import type { ServiceRequestResult } from '@/service/api/shared';
export interface BoardColumnState {
statusCode: string;
@@ -15,16 +15,24 @@ export interface BoardColumnState {
hasMore: boolean;
}
const DEFAULT_PAGE_SIZE = 20;
export type BoardBaseParams = Pick<
Api.Project.ProjectTaskSearchParams,
'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime'
>;
const DEFAULT_PAGE_SIZE = 20;
/** 看板 fetcher 入参(每列的分页与 statusCode 由 composable 内部带入) */
export interface BoardFetcherParams {
pageNo: number;
pageSize: number;
/** 首屏不传 → 后端返全部列;列加载更多传 [statusCode] → 仅返该列 */
statusCode?: string[];
}
export interface UseTaskBoardColumnsOptions {
projectId: Ref<string>;
executionId: Ref<string>;
/** 是否具备加载条件projectId / executionId 等校验由调用方做完false 时清空列表 */
canLoad: Ref<boolean>;
/**
* 刷新触发器workspace 的 statusBoard ref。
*
@@ -32,7 +40,23 @@ export interface UseTaskBoardColumnsOptions {
* 触发本 composable 重新拉看板首屏。**composable 不读它的内容**,列结构以 board-page 响应为准。
*/
refreshSignal: Ref<unknown>;
baseParams: () => BoardBaseParams;
/**
* 调用方提供的 fetcher屏蔽"单执行 / 跨执行"两套接口的差异:
* - 单执行视角 → 调 fetchGetProjectTaskBoardPage(projectId, executionId, { ...baseParams, ...params })
* - 跨执行视角 → 调 fetchGetProjectTaskBoardPageCross(projectId, { ...baseParamsCross, ...params })
*/
fetcher: (params: BoardFetcherParams) => Promise<
ServiceRequestResult<{
items: Array<{
statusCode: string;
statusName: string;
sort: number;
terminal?: boolean;
list: Api.Project.ProjectTask[];
total: number;
}>;
}>
>;
pageSize?: number;
}
@@ -40,26 +64,20 @@ export interface UseTaskBoardColumnsOptions {
* 看板按状态分列、每列独立分页(无限下拉)。
*
* 节奏:
* - 进入看板 / 任何刷新事件1 次 board-page(不带 statusCode→ 拿到所有列骨架 + 各列首页 + 各列总数。
* - 用户滚某列到底1 次 board-page`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。
*
* 不消费 searchParams.statusCode —— 每列总是查自己的 statusCode顶部搜索栏的状态过滤在看板模式下天然失效。
* - 进入看板 / 任何刷新事件1 次 fetcher(不带 statusCode→ 拿到所有列骨架 + 各列首页 + 各列总数。
* - 用户滚某列到底1 次 fetcher`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。
*/
export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) {
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
const columns = ref<BoardColumnState[]>([]);
async function refresh() {
if (!options.projectId.value || !options.executionId.value) {
if (!options.canLoad.value) {
columns.value = [];
return;
}
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
...options.baseParams(),
pageNo: 1,
pageSize
});
const result = await options.fetcher({ pageNo: 1, pageSize });
if (result.error || !result.data) {
columns.value = [];
@@ -81,18 +99,17 @@ export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) {
}
async function loadMore(statusCode: string) {
if (!options.projectId.value || !options.executionId.value) return;
if (!options.canLoad.value) return;
const col = columns.value.find(item => item.statusCode === statusCode);
if (!col || col.loading || !col.hasMore) return;
col.loading = true;
const nextPage = col.pageNo + 1;
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
...options.baseParams(),
statusCode: [statusCode],
const result = await options.fetcher({
pageNo: nextPage,
pageSize
pageSize,
statusCode: [statusCode]
});
if (result.error || !result.data) {

View File

@@ -0,0 +1,64 @@
import { taskStatusFallbackNameMap } from '../shared';
type ProjectTask = Api.Project.ProjectTask;
type TaskActionCode = Api.Project.ProjectTaskActionCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type TaskAction = Api.Project.LifecycleAction<TaskActionCode>;
/**
* 任务 actionCode → 目标 statusCode 映射。
* 看板拖拽用此映射把"拖到哪一列"反查出"应该走哪个动作",再去 task.availableActions 里找匹配项。
* auto_start 是后端在填工时自动触发,前端不暴露(useTaskActions 也做了同样过滤)。
*/
export const TASK_ACTION_TO_STATUS_CODE: Record<TaskActionCode, TaskStatusCode> = {
auto_start: 'active',
pause: 'paused',
resume: 'active',
complete: 'completed',
cancel: 'cancelled'
};
export type DragResolution = { ok: true; action: TaskAction } | { ok: false; reason: string | null };
/**
* 判定"把 task 拖到 targetStatusCode 列"是否合法,合法时返回应触发的 action。
*
* - 同列(statusCode 相等):静默拒绝(看板不支持同列手动排序)
* - 不在 task.availableActions 内的目标列:拒绝,带文案
* - complete 动作下进度 < 100:拒绝,带文案(复刻按钮的兜底)
* - auto_start:不走拖拽通道
*/
export function resolveTaskDragAction(task: ProjectTask, targetStatusCode: string): DragResolution {
if (task.statusCode === targetStatusCode) {
return { ok: false, reason: null };
}
const candidate = task.availableActions.find(
action => action.actionCode !== 'auto_start' && TASK_ACTION_TO_STATUS_CODE[action.actionCode] === targetStatusCode
);
if (!candidate) {
const targetName = taskStatusFallbackNameMap[targetStatusCode as TaskStatusCode] || targetStatusCode;
return { ok: false, reason: `当前任务不能直接变更为「${targetName}` };
}
if (candidate.actionCode === 'complete' && task.progressRate < 100) {
return { ok: false, reason: '完成任务前请先将进度调整为 100%' };
}
return { ok: true, action: candidate };
}
/**
* 卡片"是否允许拖起"。
*
* - 执行已 completed/cancelled (跨执行视角下灰显卡片):禁拖
* - availableActions 为空(后端按当前用户上下文已过滤):禁拖
* → 自然覆盖"不是负责人 / 无权限 / 终态任务 / paused 等无出度"全部场景,口径与按钮完全一致
* - 仅剩 auto_start 时也禁拖(无可暴露动作)
*/
export function isTaskDraggable(task: ProjectTask, options: { executionLocked?: boolean } = {}): boolean {
if (options.executionLocked) return false;
if (!task.availableActions.length) return false;
return task.availableActions.some(action => action.actionCode !== 'auto_start');
}

View File

@@ -0,0 +1,34 @@
import { reactive } from 'vue';
/**
* 任务 tab 的身份维度视角。
*
* 范围维度(全部 / 某状态 / 某具体执行)由父组件的 selectedStatus + selectedExecution 单独维护,
* 与本身份维度自由组合。
*
* - my: 我参与的(owner 或活跃协办)
* - all: 所有任务(需 project:task:list-all 权限)
*/
export type ViewContextType = 'my' | 'all';
export interface ViewContext {
type: ViewContextType;
}
export function useTaskViewContext() {
const state = reactive<ViewContext>({ type: 'my' });
function switchToMine() {
state.type = 'my';
}
function switchToAll() {
state.type = 'all';
}
return {
context: state,
switchToMine,
switchToAll
};
}

View File

@@ -11,20 +11,23 @@ import {
fetchGetProjectExecutionPage,
fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers,
fetchGetProjectTaskStatusBoardCross,
fetchInactiveProjectExecutionAssignee,
fetchPrecheckDeleteProjectExecution,
fetchUpdateProjectExecution
} from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useCurrentProject } from '../../shared/use-current-project';
import { useTaskPermissions } from './composables/use-task-permissions';
import ExecutionListPanel from './modules/execution-list-panel.vue';
import { useTaskViewContext } from './composables/use-task-view-context';
import ExecutionAssigneeDialog from './modules/execution-assignee-dialog.vue';
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
import ExecutionSection from './modules/execution-section.vue';
import ObjectDeleteDialog from './modules/object-delete-dialog.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import TaskWorkspace from './modules/task-workspace.vue';
import TaskWorkspaceComp from './modules/task-workspace.vue';
defineOptions({ name: 'ProjectExecution' });
@@ -47,29 +50,25 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
function transformExecutionPage(response: ExecutionPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
return { data: response.data.list, pageNum: pageNo, pageSize, total: response.data.total };
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
return { data: [], pageNum: pageNo, pageSize, total: 0 };
}
const { currentObjectId } = useCurrentProject();
const objectContextStore = useObjectContextStore();
const authStore = useAuthStore();
const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext();
const searchParams = reactive(getInitExecutionSearchParams());
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = 'active';
// 默认"全部":右侧任务列表也对应"项目全部执行下的我参与/所有任务",不预先把范围收窄
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = null;
const selectedStatus = ref<ExecutionStatusFilter>(DEFAULT_EXECUTION_STATUS);
/** 范围维度:选中的具体执行;为 null 表示"未锚定具体执行",数据来源由 selectedStatus 决定 */
const selectedExecution = ref<Api.Project.ProjectExecution | null>(null);
const projectMembers = ref<Api.Project.ProjectMember[]>([]);
const projectMemberOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
@@ -84,20 +83,62 @@ const executionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
const assigneeLoading = ref(false);
const executionStatusBoard = ref<Api.Project.StatusBoard | null>(null);
/**
* 项目下"全部执行简明列表"。一次性拉取(pageSize=-1),用于:
* 1. task-search 的「所属执行」下拉(executionOptionsForFilter)
* 2. 左侧 chip 选择某状态时,前端 filter 出"该状态下的执行 ids"传给右侧任务列表
* 3. 任务行 pill 点击切换执行时的兜底查找(handleSelectExecutionById)
*/
const allProjectExecutions = ref<Api.Project.ProjectExecution[]>([]);
const allTasksCount = ref(0);
const myTasksCount = ref(0);
const projectId = computed(() => currentObjectId.value || '');
const currentUserId = computed(() => authStore.userInfo.userId || '');
const statusActionTitle = computed(() =>
statusAction.value ? `执行状态变更:${statusAction.value.actionName}` : '执行状态变更'
);
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
/** 「所有任务」视角按钮可见度:权限码 project:task:list-all */
const showAllPerspective = computed(() => buttonCodeSet.value.has('project:task:list-all'));
/**
* 当前左侧锚定的"执行范围"——同时供 task-workspace 的任务列表/状态看板入参,以及视角按钮上的计数。
*
* 范围维度拆成两个独立字段下传,由后端组合:
* - scopedExecutionIdsForTasks:仅在锚定具体执行时下发 [id];其它场景 undefined
* - scopedExecutionStatusCodesForTasks:仅在左侧选了某状态 chip 时下发 [statusCode];其它场景 undefined
*
* 历史上"按状态过滤"用前端 filter 出 ids 中转的方式实现,会把"对执行无成员权限但对其下任务有 owner/协办权限"
* 的任务漏掉(详见 docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html)。现改为把状态码直接下传给任务接口,
* 由后端在任务可见性之上叠加"任务所属执行的状态 ∈ 白名单"过滤。
*/
const scopedExecutionIdsForTasks = computed<string[] | undefined>(() => {
if (selectedExecution.value) return [selectedExecution.value.id];
return undefined;
});
const scopedExecutionStatusCodesForTasks = computed<Api.Project.ProjectExecutionStatusCode[] | undefined>(() => {
if (selectedExecution.value) return undefined;
if (!selectedStatus.value) return undefined;
return [selectedStatus.value as Api.Project.ProjectExecutionStatusCode];
});
const deleteDialogVisible = ref(false);
const deleteExecutionDependentSummary = ref<string | null>(null);
const { canCreateTopLevelTask } = useTaskPermissions();
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
const canCreateTask = computed(() =>
selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : false
const canCreateTask = computed(() => (selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : true));
const workspaceTitle = computed(() => {
if (selectedExecution.value) return selectedExecution.value.executionName || '执行任务';
return viewContext.type === 'my' ? '我参与的' : '所有任务';
});
const workspaceSubtitle = computed(() =>
viewContext.type === 'my' ? `${myTasksCount.value}` : `${allTasksCount.value}`
);
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
@@ -132,7 +173,6 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
error: null
} as unknown as ExecutionPageResponse);
}
return fetchGetProjectExecutionPage(projectId.value, createRequestParams());
},
transform: response => transformExecutionPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
@@ -144,14 +184,9 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
immediate: false
});
function syncSelectedExecution() {
if (!data.value.length) {
selectedExecution.value = null;
return;
}
selectedExecution.value = data.value.find(item => item.id === selectedExecution.value?.id) || data.value[0];
}
const executionOptionsForFilter = computed(() =>
allProjectExecutions.value.map(item => ({ id: item.id, name: item.executionName }))
);
async function loadProjectMemberOptions() {
if (!projectId.value) {
@@ -178,7 +213,6 @@ async function loadProjectMemberOptions() {
async function reloadExecutionData(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
syncSelectedExecution();
}
async function loadExecutionStatusBoard() {
@@ -186,41 +220,121 @@ async function loadExecutionStatusBoard() {
executionStatusBoard.value = null;
return;
}
const { error, data: board } = await fetchGetProjectExecutionStatusBoard(projectId.value, createStatusBoardParams());
executionStatusBoard.value = error || !board ? null : board;
}
/** 拉取项目下"全部执行简明列表"。pageSize=-1 是后端"不分页全量"约定,见 CLAUDE.md §9 */
async function loadAllProjectExecutions() {
if (!projectId.value) {
allProjectExecutions.value = [];
return;
}
const { error, data: pageData } = await fetchGetProjectExecutionPage(projectId.value, {
pageNo: 1,
pageSize: -1
});
allProjectExecutions.value = error || !pageData ? [] : pageData.list;
}
async function loadCrossExecutionCounts() {
if (!projectId.value || !currentUserId.value) {
myTasksCount.value = 0;
allTasksCount.value = 0;
return;
}
// 视角按钮计数 = 当前左侧 chip / 执行 锚定范围内的"我参与/所有"总数
const scopedIds = scopedExecutionIdsForTasks.value;
const scopedStatusCodes = scopedExecutionStatusCodesForTasks.value;
// 短路:锚定到具体执行但 ids=[] 不会发生(scopedExecutionIdsForTasks 要么 undefined 要么 [id]);
// 状态码维度交给后端处理(空数组语义统一返空,不在前端短路)
const scopeParams: Pick<Api.Project.ProjectTaskCrossStatusBoardParams, 'executionIds' | 'executionStatusCodes'> = {};
if (scopedIds !== undefined) scopeParams.executionIds = scopedIds;
if (scopedStatusCodes !== undefined) scopeParams.executionStatusCodes = scopedStatusCodes;
const [allRes, myRes] = await Promise.all([
fetchGetProjectTaskStatusBoardCross(projectId.value, scopeParams),
fetchGetProjectTaskStatusBoardCross(projectId.value, { ...scopeParams, involveUserId: currentUserId.value })
]);
allTasksCount.value = allRes.error || !allRes.data ? 0 : allRes.data.total;
myTasksCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total;
}
async function refreshPageData() {
await Promise.all([loadProjectMemberOptions(), reloadExecutionData(), loadExecutionStatusBoard()]);
await Promise.all([
loadProjectMemberOptions(),
reloadExecutionData(),
loadExecutionStatusBoard(),
loadAllProjectExecutions(),
loadCrossExecutionCounts()
]);
}
async function handleSearch() {
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleReset() {
Object.assign(searchParams, getInitExecutionSearchParams());
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleStatusChange(status: ExecutionStatusFilter) {
async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
selectedStatus.value = status;
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
if (selectedExecution.value) selectedExecution.value = null;
// 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法
switchToMine();
await reloadExecutionData(1);
}
async function handleExecutionSearch() {
await reloadExecutionData(1);
}
async function handleExecutionResetSearch() {
Object.assign(searchParams, getInitExecutionSearchParams());
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
if (selectedExecution.value) selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function getExecutionDetail(row: Api.Project.ProjectExecution) {
if (!projectId.value) {
return row;
}
if (!projectId.value) return row;
const result = await fetchGetProjectExecution(projectId.value, row.id);
return result.error || !result.data ? row : result.data;
}
function handleSelectExecution(row: Api.Project.ProjectExecution) {
// 锚定具体执行,默认回「我参与的」视角
selectedExecution.value = row;
switchToMine();
}
async function handleSelectExecutionById(executionId: string) {
// 左栏当前分页 → 项目全部执行简明列表 → 接口详情,三层兜底
const inListMatch = data.value.find(item => item.id === executionId);
if (inListMatch) {
selectedExecution.value = inListMatch;
switchToMine();
return;
}
const allListMatch = allProjectExecutions.value.find(item => item.id === executionId);
if (allListMatch) {
selectedExecution.value = allListMatch;
switchToMine();
return;
}
if (!projectId.value) return;
const { error, data: detail } = await fetchGetProjectExecution(projectId.value, executionId);
if (error || !detail) {
window.$message?.info('该执行不在当前项目执行池中');
return;
}
selectedExecution.value = detail;
switchToMine();
}
function handleSelectPerspective(type: 'my' | 'all') {
// 只切身份维度,保留范围(具体执行 / 状态 chip)
if (type === 'my') switchToMine();
else switchToAll();
}
function openCreateExecution() {
editingExecution.value = null;
editingExecutionAssignees.value = [];
@@ -230,12 +344,10 @@ function openCreateExecution() {
async function openEditExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
if (!detail.allowEdit) {
window.$message?.warning('当前执行状态不允许编辑');
return;
}
editingExecution.value = detail;
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
@@ -245,7 +357,6 @@ async function openEditExecution(row: Api.Project.ProjectExecution) {
async function openViewExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
editingExecution.value = detail;
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
@@ -262,12 +373,10 @@ async function openMemberDialog(row: Api.Project.ProjectExecution) {
async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) {
const detail = await getExecutionDetail(row);
const targetAction = action || detail.availableActions[0] || null;
if (!targetAction) {
window.$message?.warning('当前执行暂无可用状态操作');
return;
}
statusExecution.value = detail;
statusAction.value = targetAction;
statusVisible.value = true;
@@ -278,9 +387,7 @@ async function loadExecutionAssignees(executionId: string) {
executionAssignees.value = [];
return;
}
assigneeLoading.value = true;
try {
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(projectId.value, executionId);
executionAssignees.value = error || !assignees ? [] : assignees;
@@ -290,10 +397,7 @@ async function loadExecutionAssignees(executionId: string) {
}
async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) {
if (!projectId.value) {
return;
}
if (!projectId.value) return;
const result = editingExecution.value
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, {
executionName: payload.executionName,
@@ -316,12 +420,8 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
}
async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
if (!projectId.value || !selectedExecution.value) return;
const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
selectedExecution.value = await getExecutionDetail(selectedExecution.value);
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
@@ -329,15 +429,11 @@ async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams
}
async function handleExecutionStatusSubmit(reason: string | null) {
if (!projectId.value || !statusExecution.value || !statusAction.value) {
return;
}
if (!projectId.value || !statusExecution.value || !statusAction.value) return;
const result = await fetchChangeProjectExecutionStatus(projectId.value, statusExecution.value.id, {
actionCode: statusAction.value.actionCode,
reason
});
if (!result.error) {
statusVisible.value = false;
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
@@ -345,12 +441,8 @@ async function handleExecutionStatusSubmit(reason: string | null) {
}
async function handleAddExecutionAssignee(payload: Api.Project.CreateExecutionAssigneeParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
if (!projectId.value || !selectedExecution.value) return;
const result = await fetchCreateProjectExecutionAssignee(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
await loadExecutionAssignees(selectedExecution.value.id);
}
@@ -360,15 +452,11 @@ async function handleInactiveExecutionAssignee(
assignee: Api.Project.ExecutionAssignee,
payload: Api.Project.InactiveExecutionAssigneeParams
) {
if (!projectId.value || !selectedExecution.value) {
return;
}
if (!projectId.value || !selectedExecution.value) return;
const result = await fetchInactiveProjectExecutionAssignee(projectId.value, selectedExecution.value.id, {
assigneeId: assignee.id,
data: payload
});
if (!result.error) {
await loadExecutionAssignees(selectedExecution.value.id);
}
@@ -376,8 +464,6 @@ async function handleInactiveExecutionAssignee(
async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
if (!projectId.value) return;
// 无下挂走简单二次确认;有/查询异常走原重型弹层
const precheck = await fetchPrecheckDeleteProjectExecution(projectId.value, row.id);
const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData;
@@ -393,11 +479,7 @@ async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
await window.$messageBox?.confirm(
`确定要删除执行“${row.executionName}”吗?删除后将不可见,如需恢复请联系管理员。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
);
} catch {
return;
@@ -409,7 +491,6 @@ async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
reason: '无下挂数据,用户已二次确认'
});
if (error) {
// 简化路径出错多为缓存陈旧completed 被改/并发删),兜底刷新让用户看到最新状态
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
return;
}
@@ -446,10 +527,14 @@ async function handleExecutionChangedByTask() {
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
return;
}
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
}
// 左侧 chip / 执行 选择变化 → 视角按钮上的「我参与的 / 所有任务」计数跟着刷,保持与 task-workspace 任务列表口径一致
watch([scopedExecutionIdsForTasks, scopedExecutionStatusCodesForTasks], () => {
loadCrossExecutionCounts();
});
watch(
() => projectId.value,
async () => {
@@ -464,36 +549,51 @@ watch(
<template>
<div v-if="projectId" class="project-execution-page">
<ExecutionListPanel
v-model:search-model="searchParams"
class="project-execution-page__aside"
:data="data"
:loading="loading"
:pagination="mobilePagination"
:selected-id="selectedExecution?.id || null"
:status-board="executionStatusBoard"
:selected-status="selectedStatus"
:owner-options="projectMemberOptions"
:can-create="canCreateExecution"
@select="selectedExecution = $event"
@status-change="handleStatusChange"
@search="handleSearch"
@reset="handleReset"
@create="openCreateExecution"
@edit="openEditExecution"
@view="openViewExecution"
@members="openMemberDialog"
@status-action="openExecutionStatus"
@delete="handleDeleteExecution"
/>
<div class="project-execution-page__content">
<aside class="project-execution-page__aside">
<ExecutionSection
v-model:search-model="searchParams"
class="project-execution-page__execution-section"
:data="data"
:loading="loading"
:pagination="mobilePagination"
:active-execution-id="selectedExecution?.id ?? null"
:status-board="executionStatusBoard"
:selected-status="selectedStatus"
:can-create="canCreateExecution"
:owner-options="projectMemberOptions"
@select="handleSelectExecution"
@status-change="handleExecutionStatusFilter"
@search="handleExecutionSearch"
@reset="handleExecutionResetSearch"
@create="openCreateExecution"
@edit="openEditExecution"
@view="openViewExecution"
@members="openMemberDialog"
@status-action="openExecutionStatus"
@delete="handleDeleteExecution"
/>
</aside>
<TaskWorkspace
class="project-execution-page__main"
:project-id="projectId"
:execution="selectedExecution"
:can-create="canCreateTask"
@execution-changed="handleExecutionChangedByTask"
/>
<TaskWorkspaceComp
class="project-execution-page__main"
:project-id="projectId"
:view-context="viewContext"
:execution="selectedExecution"
:execution-options="executionOptionsForFilter"
:scoped-execution-ids="scopedExecutionIdsForTasks"
:scoped-execution-status-codes="scopedExecutionStatusCodesForTasks"
:can-create="canCreateTask"
:title="workspaceTitle"
:subtitle="workspaceSubtitle"
:my-count="myTasksCount"
:all-count="allTasksCount"
:show-all="showAllPerspective"
@execution-changed="handleExecutionChangedByTask"
@select-execution="handleSelectExecutionById"
@select-perspective="handleSelectPerspective"
/>
</div>
<ExecutionOperateDialog
v-model:visible="operateVisible"
@@ -537,24 +637,52 @@ watch(
<style scoped>
.project-execution-page {
display: grid;
display: flex;
flex-direction: column;
min-height: 560px;
gap: 16px;
gap: 12px;
overflow: hidden;
grid-template-columns: 396px minmax(0, 1fr);
}
.project-execution-page__aside,
.project-execution-page__content {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 14px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.project-execution-page__aside {
display: flex;
flex-direction: column;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 8px;
background-color: #fff;
overflow: hidden;
min-width: 0;
min-height: 0;
}
.project-execution-page__execution-section {
flex: 1;
min-height: 0;
}
.project-execution-page__main {
min-width: 0;
min-height: 0;
}
@media (width <= 1280px) {
.project-execution-page {
.project-execution-page__content {
display: flex;
flex-direction: column;
overflow: auto;
}
.project-execution-page__aside {
max-height: 480px;
}
}
</style>

View File

@@ -1,53 +0,0 @@
import {
EXECUTION_STATUS_ORDER,
TASK_STATUS_ORDER,
executionStatusFallbackNameMap,
taskStatusFallbackNameMap
} from './shared';
export const mockExecutionStatusCounts: Record<Api.Project.ProjectExecutionStatusCode, number> =
EXECUTION_STATUS_ORDER.reduce(
(counts, statusCode) => ({
...counts,
[statusCode]: 0
}),
{} as Record<Api.Project.ProjectExecutionStatusCode, number>
);
export const mockTaskStatusColumns = TASK_STATUS_ORDER.map(statusCode => ({
statusCode,
statusName: taskStatusFallbackNameMap[statusCode],
tasks: [] as Api.Project.ProjectTask[]
}));
export function createEmptyExecution(projectId: string): Api.Project.ProjectExecution {
const now = new Date().toISOString();
return {
id: '',
projectId,
projectRequirementId: null,
projectRequirementName: null,
projectRequirementStatusCode: null,
executionName: '',
executionType: null,
ownerId: '',
ownerNickname: null,
statusCode: 'pending',
statusName: executionStatusFallbackNameMap.pending,
terminal: false,
allowEdit: true,
availableActions: [],
plannedStartDate: null,
plannedEndDate: null,
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
priority: '1',
priorityName: null,
executionDesc: null,
lastStatusReason: null,
createTime: now,
updateTime: now
};
}

View File

@@ -2,9 +2,8 @@
import { computed, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import type { PaginationProps } from 'element-plus';
import { Calendar, Flag, Link, Plus, TrendCharts, User } from '@element-plus/icons-vue';
import { Calendar, Flag, Link, Plus, User } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
import { projectRequirementStatusRecord } from '../../requirement/shared/requirement-master-data';
@@ -18,7 +17,7 @@ import IconMdiPlay from '~icons/mdi/play';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'ProjectExecutionListPanel' });
defineOptions({ name: 'ProjectTaskExecutionSection' });
type ExecutionStatusFilter = string | null;
@@ -26,17 +25,14 @@ interface Props {
data: Api.Project.ProjectExecution[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
selectedId: string | null;
/** 当前激活的执行 id跨执行视角时为 null对应没有任何卡片高亮 */
activeExecutionId: string | null;
statusBoard: Api.Project.StatusBoard | null;
selectedStatus: ExecutionStatusFilter;
ownerOptions: Api.SystemManage.UserSimple[];
canCreate: boolean;
ownerOptions: Api.SystemManage.UserSimple[];
}
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
const props = defineProps<Props>();
interface Emits {
(e: 'select', row: Api.Project.ProjectExecution): void;
(e: 'status-change', status: ExecutionStatusFilter): void;
@@ -54,21 +50,9 @@ interface Emits {
): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const router = useRouter();
function handleRequirementClick(row: Api.Project.ProjectExecution) {
if (!row.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
query: {
objectId: row.projectId,
requirementId: row.projectRequirementId
}
});
}
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
function handleSearch() {
@@ -89,38 +73,28 @@ function handleOwnerSelect(id: string | null | undefined) {
handleSearch();
}
function handlePrioritySelect(value: string | number | null | undefined) {
searchModel.value.priority = value ? String(value) : undefined;
handleSearch();
}
function handleReset() {
emit('reset');
}
const paginationVisible = computed(() => {
const total = Number(props.pagination.total || 0);
return total > 0;
});
const router = useRouter();
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
const totalCount = computed(() => props.statusBoard?.total ?? 0);
const statusItems = computed(() => [
{
key: null,
label: '全部',
count: totalCount.value
},
const statusChips = computed(() => [
{ key: null as ExecutionStatusFilter, label: '全部', count: totalCount.value },
...(props.statusBoard?.items ?? []).map(item => ({
key: item.statusCode,
key: item.statusCode as ExecutionStatusFilter,
label: item.statusName,
count: item.count
}))
]);
function handleStatusClick(status: ExecutionStatusFilter) {
emit('status-change', status);
const paginationVisible = computed(() => Number(props.pagination.total || 0) > 0);
function handlePageChange(page: number) {
props.pagination['current-change']?.(page);
}
function getProjectRequirementStatusName(code: string | null) {
@@ -128,16 +102,15 @@ function getProjectRequirementStatusName(code: string | null) {
return projectRequirementStatusRecord[code as keyof typeof projectRequirementStatusRecord] ?? code;
}
function handleSelect(row: Api.Project.ProjectExecution) {
emit('select', row);
}
function handlePageChange(page: number) {
props.pagination['current-change']?.(page);
}
function handleSizeChange(pageSize: number) {
props.pagination['size-change']?.(pageSize);
function handleRequirementClick(row: Api.Project.ProjectExecution) {
if (!row.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
query: {
objectId: row.projectId,
requirementId: row.projectRequirementId
}
});
}
interface ExecutionAction {
@@ -156,7 +129,6 @@ const STATUS_ACTION_ICON_MAP: Record<string, object> = {
complete: markRaw(IconMdiCheckCircleOutline)
};
// type cancel =pause =complete =绿resume/start =
const STATUS_ACTION_TYPE_MAP: Record<string, ExecutionAction['type']> = {
cancel: 'danger',
pause: 'warning',
@@ -165,7 +137,6 @@ const STATUS_ACTION_TYPE_MAP: Record<string, ExecutionAction['type']> = {
start: 'primary'
};
//
const STATUS_ACTION_ORDER: Record<string, number> = {
pause: 1,
cancel: 2,
@@ -177,9 +148,6 @@ const STATUS_ACTION_ORDER: Record<string, number> = {
function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
const actions: ExecutionAction[] = [];
// view
// pending/active + ( OR )
if (canEditExecution(row)) {
actions.push({
key: 'edit',
@@ -190,8 +158,6 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
});
}
// / /
// ""dialog " / / owner" isMutable
if (canSeeExecutionAssigneeEntry(row)) {
actions.push({
key: 'members',
@@ -202,8 +168,6 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
});
}
// availableActionsowner-only spec §3.4.1
// type/icon
const sortedActions = [...row.availableActions].sort(
(a, b) => (STATUS_ACTION_ORDER[a.actionCode] ?? 99) - (STATUS_ACTION_ORDER[b.actionCode] ?? 99)
);
@@ -222,22 +186,22 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
</script>
<template>
<section class="execution-list-panel">
<header class="execution-list-panel__header">
<h3 class="execution-list-panel__title">执行池</h3>
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="emit('create')">新增</ElButton>
<section class="execution-section">
<header class="execution-section__header">
<h3 class="execution-section__title">执行池</h3>
<ElButton v-if="canCreate" type="primary" size="small" :icon="Plus" @click="emit('create')">新增</ElButton>
</header>
<div class="execution-list-panel__search">
<div class="execution-section__search">
<ElInput
:model-value="searchModel.keyword ?? ''"
class="execution-search-input"
class="execution-section__search-input"
placeholder="搜索执行名称"
@update:model-value="handleKeywordInput"
@keyup.enter="handleSearch"
>
<template #suffix>
<ElIcon v-if="searchModel.keyword" class="execution-search-input__clear" @click="handleKeywordClear">
<ElIcon v-if="searchModel.keyword" class="execution-section__search-clear" @click="handleKeywordClear">
<icon-mdi-close-circle />
</ElIcon>
</template>
@@ -245,7 +209,7 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<ElSelect
:model-value="searchModel.ownerId ?? null"
class="execution-owner-select"
class="execution-section__owner-select"
placeholder="负责人"
clearable
filterable
@@ -254,177 +218,150 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
<div class="execution-search__icons">
<div class="execution-section__search-icons">
<ElTooltip content="重置" placement="top">
<ElButton link class="execution-search-input__btn" @click="handleReset">
<ElButton link class="execution-section__search-btn" @click="handleReset">
<icon-mdi-refresh class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="搜索" placement="top">
<ElButton link class="execution-search-input__btn" type="primary" @click="handleSearch">
<ElButton link type="primary" class="execution-section__search-btn" @click="handleSearch">
<icon-ic-round-search class="text-15px" />
</ElButton>
</ElTooltip>
</div>
</div>
<div class="execution-list-panel__filter">
<DictSelect
:model-value="searchModel.priority ?? null"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
class="execution-priority-select"
placeholder="筛选优先级"
clearable
show-remark
@update:model-value="handlePrioritySelect"
/>
</div>
<div class="execution-status-grid" aria-label="执行状态筛选">
<div class="execution-section__grid" aria-label="执行状态筛选">
<button
v-for="item in statusItems"
v-for="item in statusChips"
:key="item.key || 'all'"
type="button"
class="execution-status-grid__item"
class="execution-status-cell"
:class="{ 'is-active': selectedStatus === item.key }"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusClick(item.key)"
@click="emit('status-change', item.key)"
>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</button>
</div>
<ElScrollbar class="execution-list-panel__scrollbar">
<ElScrollbar class="execution-section__scrollbar">
<ElSkeleton v-if="loading" :rows="5" animated />
<ElEmpty v-else-if="data.length === 0" class="execution-list-panel__empty" description="暂无执行项" />
<div v-else class="execution-list-panel__list">
<ElEmpty v-else-if="data.length === 0" class="execution-section__empty" description="暂无执行项" />
<div v-else class="execution-section__list">
<article
v-for="row in data"
:key="row.id"
class="execution-item"
:class="{ 'is-active': selectedId === row.id }"
@click="handleSelect(row)"
class="exec-item"
:class="{ 'is-active': activeExecutionId === row.id }"
@click="emit('select', row)"
>
<div class="execution-item__main">
<div class="execution-item__top">
<strong
class="execution-item__name"
role="button"
tabindex="0"
@click.stop="emit('view', row)"
@keydown.enter.stop.prevent="emit('view', row)"
>
{{ row.executionName || '未命名执行' }}
</strong>
<DictTag
class="execution-item__status-tag"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="row.priority"
effect="light"
size="small"
/>
<ElTag
class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)"
effect="light"
size="small"
>
{{ getExecutionStatusName(row) }}
</ElTag>
<div class="execution-item__actions" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="execution-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-14px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canDeleteExecution(row)" content="删除">
<ElButton link type="danger" class="execution-action-btn" @click="emit('delete', row)">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</div>
<div class="exec-item__top">
<strong
class="exec-item__name"
role="button"
tabindex="0"
@click.stop="emit('view', row)"
@keydown.enter.stop.prevent="emit('view', row)"
>
{{ row.executionName || '未命名执行' }}
</strong>
<DictTag
class="exec-item__tag"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="row.priority"
effect="light"
size="small"
/>
<ElTag class="exec-item__tag" :type="getExecutionStatusTagType(row.statusCode)" effect="light" size="small">
{{ getExecutionStatusName(row) }}
</ElTag>
<div class="exec-item__actions" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="exec-item__action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-14px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canDeleteExecution(row)" content="删除">
<ElButton link type="danger" class="exec-item__action-btn" @click="emit('delete', row)">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</div>
<div class="execution-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ row.ownerNickname || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
</span>
<span>
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
</span>
<span v-if="row.projectRequirementId && row.projectRequirementName" class="execution-item__requirement">
<ElIcon><Link /></ElIcon>
<ElTooltip
:content="getProjectRequirementStatusName(row.projectRequirementStatusCode)"
placement="top"
:show-after="200"
:disabled="!row.projectRequirementStatusCode"
</div>
<div class="exec-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ row.ownerNickname || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
</span>
<span>
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
</span>
<span v-if="row.projectRequirementId && row.projectRequirementName" class="exec-item__requirement">
<ElIcon><Link /></ElIcon>
<ElTooltip
:content="getProjectRequirementStatusName(row.projectRequirementStatusCode)"
placement="top"
:show-after="200"
:disabled="!row.projectRequirementStatusCode"
>
<span
class="exec-item__requirement-name"
role="button"
tabindex="0"
@click.stop="handleRequirementClick(row)"
@keydown.enter.stop.prevent="handleRequirementClick(row)"
>
<span
class="execution-item__requirement-name"
role="button"
tabindex="0"
@click.stop="handleRequirementClick(row)"
@keydown.enter.stop.prevent="handleRequirementClick(row)"
>
{{ row.projectRequirementName }}
</span>
</ElTooltip>
</span>
<span class="execution-item__progress-row">
<ElIcon><TrendCharts /></ElIcon>
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
</span>
</div>
{{ row.projectRequirementName }}
</span>
</ElTooltip>
</span>
<span class="exec-item__progress-row">
<ElProgress class="exec-item__progress" :percentage="row.progressRate" :stroke-width="6" />
</span>
</div>
</article>
</div>
</ElScrollbar>
<div v-if="paginationVisible" class="execution-list-panel__pagination">
<div v-if="paginationVisible" class="execution-section__pagination">
<ElPagination
size="small"
background
layout="total, prev, pager, next"
layout="prev, pager, next"
v-bind="pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped lang="scss">
.execution-list-panel {
.execution-section {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
min-height: 0;
flex: 1;
gap: 12px;
height: 100%;
padding: 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 8px;
background-color: #fff;
}
.execution-list-panel__header {
.execution-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
min-height: 32px;
}
.execution-list-panel__title {
.execution-section__title {
margin: 0;
color: rgb(15 23 42 / 94%);
font-size: 16px;
@@ -432,38 +369,33 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
line-height: 1.4;
}
.execution-list-panel__search {
.execution-section__search {
display: flex;
align-items: center;
gap: 8px;
}
.execution-list-panel__filter {
display: flex;
align-items: center;
gap: 8px;
}
.execution-priority-select {
flex: 1;
}
.execution-search-input {
.execution-section__search-input {
width: 140px;
flex: 0 0 auto;
}
.execution-search-input__clear {
.execution-section__search-clear {
color: rgb(192 196 204);
cursor: pointer;
transition: color 0.2s ease;
}
.execution-search-input__clear:hover {
.execution-section__search-clear:hover {
color: rgb(144 147 153);
}
.execution-search__icons {
.execution-section__owner-select {
width: 140px;
flex: 0 0 auto;
}
.execution-section__search-icons {
display: flex;
align-items: center;
gap: 6px;
@@ -471,36 +403,31 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
margin-left: auto;
}
.execution-search-input__btn {
.execution-section__search-icons :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.execution-section__search-btn) {
width: 24px;
min-width: 24px;
height: 24px;
padding: 0;
}
.execution-owner-select {
width: 140px;
flex: 0 0 auto;
}
.execution-search__icons :deep(.el-button + .el-button) {
margin-left: 0;
}
.execution-status-grid {
.execution-section__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.execution-status-grid__item {
.execution-status-cell {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
min-height: 40px;
padding: 8px 10px;
border: 1px solid rgb(226 232 240 / 92%);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background-color: rgb(248 250 252 / 80%);
color: rgb(51 65 85 / 96%);
@@ -511,18 +438,18 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
color 0.16s ease;
}
.execution-status-grid__item:hover {
.execution-status-cell:hover {
border-color: rgb(148 163 184 / 80%);
background-color: #fff;
}
.execution-status-grid__item.is-active {
border-color: rgb(64 158 255 / 68%);
background-color: rgb(236 245 255 / 92%);
.execution-status-cell.is-active {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.execution-status-grid__item span {
.execution-status-cell span {
overflow: hidden;
min-width: 0;
font-size: 13px;
@@ -530,128 +457,138 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
white-space: nowrap;
}
.execution-status-grid__item strong {
.execution-status-cell strong {
flex: 0 0 auto;
font-size: 16px;
font-weight: 700;
}
.execution-list-panel__scrollbar {
min-height: 0;
.execution-section__scrollbar {
flex: 1;
min-height: 0;
}
.execution-list-panel__empty {
padding: 32px 0;
.execution-section__empty {
padding: 24px 0;
}
.execution-list-panel__list {
.execution-section__list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding-right: 4px;
}
.execution-list-panel__pagination {
display: flex;
justify-content: flex-end;
padding-top: 4px;
overflow: hidden;
}
.execution-item {
min-width: 0;
padding: 10px;
.exec-item {
padding: 9px 10px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 6px;
background-color: #fff;
cursor: pointer;
transition:
border-color 0.16s ease,
background-color 0.16s ease;
background 0.16s ease,
border-color 0.16s ease;
}
.execution-item:hover {
.exec-item:hover {
background-color: rgb(243 244 246 / 60%);
border-color: rgb(148 163 184 / 76%);
background-color: rgb(248 250 252 / 68%);
}
.execution-item.is-active {
border-color: rgb(64 158 255 / 68%);
background-color: rgb(236 245 255 / 78%);
.exec-item.is-active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
border-left-width: 3px;
padding-left: 8px;
}
.execution-item__main {
min-width: 0;
flex: 1;
}
.execution-item__top {
.exec-item__top {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
min-height: 24px;
gap: 6px;
min-height: 22px;
}
.execution-item__status-tag {
flex: 0 0 auto;
}
.execution-item__name {
.exec-item__name {
overflow: hidden;
min-width: 0;
color: rgb(15 23 42 / 94%);
font-size: 14px;
font-weight: 700;
line-height: 1.5;
font-size: 13px;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.16s ease;
}
.execution-item__name:hover,
.execution-item__name:focus-visible {
.exec-item__name:hover,
.exec-item__name:focus-visible {
color: var(--el-color-primary);
outline: none;
}
.execution-item__meta {
.exec-item__tag {
flex: 0 0 auto;
}
.exec-item__actions {
display: none;
align-items: center;
gap: 4px;
flex: 0 0 auto;
margin-left: auto;
}
.exec-item:hover .exec-item__actions {
display: inline-flex;
}
.exec-item__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.exec-item__action-btn) {
padding: 2px;
min-width: auto;
height: auto;
line-height: 1;
}
.exec-item__meta {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 8px;
color: rgb(100 116 139 / 94%);
font-size: 12px;
line-height: 1.5;
gap: 4px;
margin-top: 6px;
color: rgb(100 116 139);
font-size: 11.5px;
line-height: 1.45;
}
.execution-item__meta span {
.exec-item__meta span {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 4px;
min-width: 0;
}
.execution-item__progress-row {
.exec-item__progress-row {
width: 100%;
}
.execution-item__progress {
.exec-item__progress {
flex: 1;
min-width: 0;
}
.execution-item__requirement {
.exec-item__requirement {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 320px;
max-width: 100%;
overflow: hidden;
}
.execution-item__requirement-name {
.exec-item__requirement-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -659,37 +596,13 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
cursor: pointer;
}
.execution-item__requirement-name:hover {
.exec-item__requirement-name:hover {
text-decoration: underline;
}
.execution-item__actions {
.execution-section__pagination {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
.execution-item__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.execution-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
@media (width <= 1280px) {
.execution-item__top {
align-items: flex-start;
flex-wrap: wrap;
}
.execution-item__actions {
justify-content: flex-end;
}
justify-content: center;
padding-top: 4px;
}
</style>

View File

@@ -1,19 +1,36 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { Calendar, Flag, User } from '@element-plus/icons-vue';
import { computed, 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';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions';
import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns';
import {
type BoardFetcherParams,
type UseTaskBoardColumnsOptions,
useTaskBoardColumns
} from '../composables/use-task-board-columns';
import { isTaskDraggable, resolveTaskDragAction } from '../composables/use-task-drag';
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
interface Props {
projectId: string;
executionId: string;
canLoad: boolean;
statusBoard: Api.Project.StatusBoard | null;
baseParams: BoardBaseParams;
/** 看板 fetcher内部按"单执行 / 跨执行"分流composable 只负责列状态管理 */
fetcher: UseTaskBoardColumnsOptions['fetcher'];
/**
* 跨执行模式:卡片显示「所属执行 pill」(可点切换执行);
* 已完成执行的任务卡片灰显(文档建议:跨执行场景下 executionStatusCode 表达执行生命周期)
*/
crossExecutionMode?: boolean;
/**
* 父组件 StatusActionDialog 的可见状态。
* 拖拽触发的状态变更走和按钮同一路径(emit status-action),期间该卡片显示 pending 视觉;
* dialog 从 true → false 时(无论 submit 还是 cancel)清掉 pending,把卡片视觉还回去。
*/
statusActionVisible?: boolean;
}
interface Emits {
@@ -21,6 +38,7 @@ interface Emits {
(e: 'edit', row: Api.Project.ProjectTask): void;
(e: 'report', row: Api.Project.ProjectTask): void;
(e: 'delete', row: Api.Project.ProjectTask): void;
(e: 'select-execution', executionId: string): void;
(
e: 'status-action',
row: Api.Project.ProjectTask,
@@ -28,17 +46,161 @@ interface Emits {
): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
crossExecutionMode: false,
statusActionVisible: false
});
const emit = defineEmits<Emits>();
const DRAG_GROUP = 'project-task-board';
/** 拖拽触发后等待 dialog 反馈的任务 id;同一时刻最多一个(dialog 是模态) */
const pendingTaskId = ref<string | null>(null);
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
const PENDING_FALLBACK_MS = 8000;
interface DragEndEvent {
oldIndex?: number;
newIndex?: number;
from: HTMLElement;
to: HTMLElement;
}
function clearPending() {
pendingTaskId.value = null;
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
}
/**
* 设 pending 并启动兜底 timer。
* 兜底场景:父组件 handleStatusAction 在 await getTaskDetail 后因 availableActions 为空 / complete 进度
* 不足等原因 warning return,dialog 从未打开 → watch statusActionVisible 永远不会触发清零。
* timer 到时间强制还原卡片视觉(实际正常路径上 dialog 关闭信号会先于 timer 触发)。
*/
function startPending(taskId: string) {
pendingTaskId.value = taskId;
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => {
if (pendingTaskId.value === taskId) clearPending();
}, PENDING_FALLBACK_MS);
}
const { columns, loadMore } = useTaskBoardColumns({
projectId: toRef(props, 'projectId'),
executionId: toRef(props, 'executionId'),
canLoad: toRef(props, 'canLoad'),
// statusBoard 在此仅作"刷新事件源"workspace 任何变更后都会重新拉它ref 引用变即触发本看板重拉首屏
refreshSignal: toRef(props, 'statusBoard'),
baseParams: () => props.baseParams
fetcher: (params: BoardFetcherParams) => props.fetcher(params)
});
function isExecutionLocked(task: Api.Project.ProjectTask): boolean {
if (!props.crossExecutionMode) return false;
return task.executionStatusCode === 'completed' || task.executionStatusCode === 'cancelled';
}
function isCardDraggable(task: Api.Project.ProjectTask): boolean {
if (pendingTaskId.value === task.id) return false;
return isTaskDraggable(task, { executionLocked: isExecutionLocked(task) });
}
/**
* 卡片"为什么不可拖"——给视觉标识和拖时 toast 复用同一份文案。
* pending 卡片是临时态(提交中),不算"无权",不显示锁标识。
*/
function getLockedReason(task: Api.Project.ProjectTask): string | null {
if (pendingTaskId.value === task.id) return null;
if (isExecutionLocked(task)) return '所属执行已结束,任务不可变更状态';
const hasExposedAction = task.availableActions.some(action => action.actionCode !== 'auto_start');
if (!hasExposedAction) return '你暂无该任务的状态变更权限';
return null;
}
/**
* 尝试拖 locked 卡片时 SortableJS 触发 onFilter,弹 toast 解释原因。
* 同任务 1.2s 内不重复弹(防 mousedown 重复触发刷屏)。
* 详情入口已收敛到标题点击,不再需要"filter 后抑制 click"那套兼容。
*/
let lastFilterTaskId: string | null = null;
let lastFilterAt = 0;
function handleDragFilter(event: { item: HTMLElement }) {
const taskId = event.item?.dataset?.taskId;
if (!taskId) return;
const now = Date.now();
if (lastFilterTaskId === taskId && now - lastFilterAt < 1200) return;
lastFilterTaskId = taskId;
lastFilterAt = now;
let task: Api.Project.ProjectTask | undefined;
for (const col of columns.value) {
task = col.tasks.find(item => item.id === taskId);
if (task) break;
}
if (!task) return;
const reason = getLockedReason(task);
if (reason) window.$message?.warning(reason);
}
function resolveStatusCodeFromEl(el: HTMLElement | null | undefined): string | undefined {
// vue-draggable-plus 通过 h() 手动构造 vnode 不可靠地透传 attrs,所以 data 属性挂在外层 wrapper 上,
// SortableJS 的 from/to 指向内部 VueDraggable 根 div,需要向上 closest 查找
return el?.closest<HTMLElement>('[data-board-column]')?.dataset.boardColumn;
}
function handleDragEnd(event: DragEndEvent) {
const { from, to, oldIndex, newIndex } = event;
if (oldIndex === undefined || newIndex === undefined) return;
const fromStatusCode = resolveStatusCodeFromEl(from);
const toStatusCode = resolveStatusCodeFromEl(to);
if (!fromStatusCode || !toStatusCode) return;
const fromCol = columns.value.find(item => item.statusCode === fromStatusCode);
const toCol = columns.value.find(item => item.statusCode === toStatusCode);
if (!fromCol || !toCol) return;
// 同列拖动:看板不支持手动排序,还原顺序后静默
if (fromCol === toCol) {
if (oldIndex !== newIndex) {
const [moved] = toCol.tasks.splice(newIndex, 1);
toCol.tasks.splice(oldIndex, 0, moved);
}
return;
}
// 跨列:vue-draggable-plus 已经把任务从 from splice 到 to;先无条件还原,把"是否真正变更"完全交给后端结果
const [task] = toCol.tasks.splice(newIndex, 1);
if (!task) return;
fromCol.tasks.splice(oldIndex, 0, task);
const resolution = resolveTaskDragAction(task, toStatusCode);
if (!resolution.ok) {
if (resolution.reason) window.$message?.warning(resolution.reason);
return;
}
// 合法:走和按钮点击同一路径,父组件打开 StatusActionDialog 收 reason
startPending(task.id);
emit('status-action', task, resolution.action);
}
// dialog 关闭(submit 完成或 cancel 都会触发) → 清 pending,把卡片视觉还回去
// submit 成功时父组件会重拉 statusBoard → 看板首屏重渲染,卡片自然到位
watch(
() => props.statusActionVisible,
(next, prev) => {
// false → true:dialog 真正打开,撤掉兜底 timer,关闭信号接管
if (!prev && next && pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
// true → false:正常关闭(submit/cancel)
if (prev && !next) clearPending();
}
);
// 看板卡片操作按钮(与表格操作列同语义)。
// 兼容 useTaskActions 的"叶子判定"需求:拍平当前已加载的全部任务做集合。
const allLoadedTasks = computed(() => columns.value.flatMap(item => item.tasks));
@@ -92,6 +254,10 @@ onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
sentinelRegistry.clear();
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
});
</script>
@@ -110,50 +276,111 @@ onBeforeUnmount(() => {
:description="`暂无${column.title}任务`"
:image-size="64"
/>
<article
v-for="task in column.tasks"
:key="task.id"
class="task-board-card-item"
@click="emit('detail', task)"
>
<div class="task-board-card-item__top">
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
<div class="task-board-card-item__top-tags">
<DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="task.priority" effect="light" size="small" />
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
{{ getTaskStatusName(task) }}
</ElTag>
</div>
</div>
<div :data-board-column="column.statusCode" class="task-board-column__list-wrap">
<VueDraggable
v-model="column.tasks"
:group="DRAG_GROUP"
:animation="180"
filter=".task-board-card-item--locked"
:prevent-on-filter="false"
drag-class="task-board-card-item--dragging"
ghost-class="task-board-card-item--ghost"
class="task-board-column__list"
@end="handleDragEnd"
@filter="handleDragFilter"
>
<article
v-for="task in column.tasks"
:key="task.id"
class="task-board-card-item"
:class="{
'is-dimmed': isExecutionLocked(task),
'task-board-card-item--locked': !isCardDraggable(task),
'task-board-card-item--pending': pendingTaskId === task.id
}"
:data-task-id="task.id"
>
<div class="task-board-card-item__top">
<strong
class="task-board-card-item__title"
role="button"
tabindex="0"
@click.stop="emit('detail', task)"
@keydown.enter.stop.prevent="emit('detail', task)"
>
{{ task.taskTitle || '未命名任务' }}
</strong>
<div class="task-board-card-item__top-tags">
<DictTag
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="task.priority"
effect="light"
size="small"
/>
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
{{ getTaskStatusName(task) }}
</ElTag>
<ElTooltip
v-if="getLockedReason(task)"
:content="getLockedReason(task) ?? ''"
placement="top"
:show-after="120"
>
<ElIcon class="task-board-card-item__lock" @click.stop>
<Lock />
</ElIcon>
</ElTooltip>
</div>
</div>
<div class="task-board-card-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划 {{ formatDateRange(task.plannedStartDate, task.plannedEndDate) }}
</span>
<span>
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(task.actualStartDate, task.actualEndDate) }}
</span>
</div>
<div v-if="crossExecutionMode && task.executionId" class="task-board-card-item__exec">
<ElTag
size="small"
type="info"
effect="plain"
class="task-board-card-item__exec-tag"
:title="task.executionName || '切换到该执行'"
@click.stop="emit('select-execution', task.executionId)"
>
{{ task.executionName || '未命名执行' }}
</ElTag>
</div>
<div class="task-board-card-item__progress">
<span>进度 {{ getProgressText(task.progressRate) }}</span>
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
</div>
<div class="task-board-card-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划 {{ formatDateRange(task.plannedStartDate, task.plannedEndDate) }}
</span>
<span v-if="!crossExecutionMode">
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(task.actualStartDate, task.actualEndDate) }}
</span>
</div>
<div v-if="createActions(task).length" class="task-board-card-item__actions" @click.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" />
</ElButton>
</ElTooltip>
</div>
</article>
<div class="task-board-card-item__progress">
<span>进度 {{ getProgressText(task.progressRate) }}</span>
<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>
<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" />
</ElButton>
</ElTooltip>
</div>
<div v-if="pendingTaskId === task.id" class="task-board-card-item__pending-overlay">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>提交中</span>
</div>
</article>
</VueDraggable>
</div>
<div
v-if="column.hasMore"
@@ -218,23 +445,114 @@ onBeforeUnmount(() => {
padding: 10px;
}
.task-board-column__list {
display: block;
min-height: 12px;
}
.task-board-card-item {
position: relative;
padding: 10px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 6px;
margin-bottom: 10px;
background-color: #fff;
cursor: pointer;
cursor: grab;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.task-board-card-item:active {
cursor: grabbing;
}
.task-board-card-item:hover {
border-color: rgb(148 163 184 / 76%);
box-shadow: 0 6px 18px rgb(15 23 42 / 8%);
}
.task-board-card-item.is-dimmed {
opacity: 0.55;
background-color: rgb(248 250 252 / 80%);
}
.task-board-card-item.is-dimmed:hover {
opacity: 0.8;
}
// 不可拖卡片(灰显执行 / 无可推进 action):光标提示 + 取消"可抓"
.task-board-card-item--locked {
cursor: pointer;
}
.task-board-card-item--locked:active {
cursor: pointer;
}
// 拖拽中:跟手卡片样式(SortableJS dragClass)
.task-board-card-item--dragging {
opacity: 0.92;
box-shadow: 0 12px 28px rgb(15 23 42 / 18%);
transform: rotate(1.2deg);
}
// 原位置占位(SortableJS ghostClass)
.task-board-card-item--ghost {
opacity: 0.36;
background-color: rgb(241 245 249);
border-style: dashed;
}
// 提交中:卡片整体淡化,中央 overlay 显示 spinner
.task-board-card-item--pending {
cursor: progress;
pointer-events: none;
}
.task-board-card-item--pending:active {
cursor: progress;
}
.task-board-card-item__pending-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 6px;
background-color: rgb(255 255 255 / 72%);
backdrop-filter: blur(1px);
color: rgb(71 85 105);
font-size: 12px;
font-weight: 600;
}
.task-board-card-item__pending-overlay .is-loading {
animation: task-board-pending-spin 0.9s linear infinite;
font-size: 16px;
}
@keyframes task-board-pending-spin {
to {
transform: rotate(360deg);
}
}
.task-board-card-item__exec {
margin-top: 8px;
}
.task-board-card-item__exec-tag {
cursor: pointer;
max-width: 100%;
}
.task-board-card-item__exec-tag:hover {
opacity: 0.85;
}
.task-board-card-item__top {
display: flex;
align-items: flex-start;
@@ -249,12 +567,30 @@ onBeforeUnmount(() => {
flex: 0 0 auto;
}
.task-board-card-item__lock {
font-size: 13px;
color: rgb(148 163 184);
cursor: help;
}
.task-board-card-item__title {
min-width: 0;
color: rgb(15 23 42 / 94%);
font-size: 14px;
line-height: 1.5;
word-break: break-word;
cursor: pointer;
transition: color 0.16s ease;
}
.task-board-card-item__title:hover {
color: var(--el-color-primary);
}
.task-board-card-item__title:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
border-radius: 2px;
}
.task-board-card-item__meta {

View File

@@ -2,13 +2,17 @@
import { computed } from 'vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import type { TaskWorkspaceSearchModel } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskSearch' });
interface Props {
model: Api.Project.ProjectTaskSearchParams;
model: TaskWorkspaceSearchModel;
userOptions: Api.SystemManage.UserSimple[];
statusOptions: Api.Project.StatusBoardItem[];
executionOptions: { id: string; name: string }[];
/** 是否在搜索条里展示「所属执行」字段;仅跨执行视角下为 true */
showExecutionField: boolean;
disabled?: boolean;
}
@@ -23,51 +27,66 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>();
const ownerOptions = computed(() =>
props.userOptions.map(item => ({
label: item.nickname,
value: item.id
}))
const ownerOptions = computed(() => props.userOptions.map(item => ({ label: item.nickname, value: item.id })));
const executionFieldOptions = computed(() =>
props.executionOptions.map(item => ({ label: item.name, value: item.id }))
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键词',
type: 'input',
placeholder: '任务名称/说明'
},
{
key: 'priority',
label: '优先级',
type: 'dict',
dictCode: RDMS_REQ_PRIORITY_DICT_CODE,
placeholder: '全部优先级',
showRemark: true
},
{
key: 'statusCode',
label: '状态',
type: 'select',
options: props.statusOptions.map(item => ({
label: item.statusName,
value: item.statusCode
})),
placeholder: '全部状态'
},
{
key: 'ownerId',
label: '负责人',
type: 'select',
options: ownerOptions.value,
placeholder: '全部负责人'
},
{
key: 'updateTime',
label: '更新时间',
type: 'dateRange'
const dueRangeOptions = [
{ label: '仅逾期', value: 'overdue' },
{ label: '今日截止', value: 'today' },
{ label: '本周到期', value: 'thisWeek' }
];
const fields = computed<SearchField[]>(() => {
const list: SearchField[] = [
{ key: 'keyword', label: '关键词', type: 'input', placeholder: '任务名称/说明' },
{
key: 'priority',
label: '优先级',
type: 'dict',
dictCode: RDMS_REQ_PRIORITY_DICT_CODE,
placeholder: '全部优先级',
showRemark: true
},
{
key: 'statusCode',
label: '状态',
type: 'select',
options: props.statusOptions.map(item => ({ label: item.statusName, value: item.statusCode })),
placeholder: '全部状态'
},
{
key: 'ownerId',
label: '负责人',
type: 'select',
options: ownerOptions.value,
placeholder: '全部负责人'
},
{
key: 'dueRange',
label: '截止',
type: 'select',
options: dueRangeOptions,
placeholder: '全部时间'
}
];
if (props.showExecutionField) {
list.push({
key: 'executionId',
label: '所属执行',
type: 'select',
options: executionFieldOptions.value,
placeholder: '全部执行'
});
}
]);
list.push({ key: 'updateTime', label: '更新时间', type: 'dateRange' });
return list;
});
</script>
<template>

View File

@@ -2,6 +2,7 @@
import { computed, toRef } from 'vue';
import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { useAuthStore } from '@/store/modules/auth';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions';
@@ -12,6 +13,11 @@ interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
/**
* 跨执行模式:显示「所属执行 pill」、「角色」列隐藏「父任务」、「实际周期」、「最近更新」列。
* 由父组件根据视角类型传入:跨执行视角(my/all) = true单执行视角 = false。
*/
crossExecutionMode?: boolean;
}
interface Emits {
@@ -19,6 +25,7 @@ interface Emits {
(e: 'edit', row: Api.Project.ProjectTask): void;
(e: 'report', row: Api.Project.ProjectTask): void;
(e: 'delete', row: Api.Project.ProjectTask): void;
(e: 'select-execution', executionId: string): void;
(
e: 'status-action',
row: Api.Project.ProjectTask,
@@ -26,9 +33,23 @@ interface Emits {
): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
crossExecutionMode: false
});
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
type RoleTagType = 'success' | 'info';
function getRoleLabel(row: Api.Project.ProjectTask): { label: string; type: RoleTagType | undefined } {
if (!currentUserId.value) return { label: '--', type: undefined };
if (row.ownerId === currentUserId.value) return { label: '负责人', type: 'success' };
if (row.assignees?.some(item => item.userId === currentUserId.value)) return { label: '协办', type: 'info' };
return { label: '旁观', type: undefined };
}
const { createActions } = useTaskActions(toRef(props, 'data'), {
edit: row => emit('edit', row),
report: row => emit('report', row),
@@ -84,6 +105,30 @@ function handleSizeChange(pageSize: number) {
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="crossExecutionMode" label="所属执行" width="200" show-overflow-tooltip>
<template #default="{ row }">
<ElTag
v-if="row.executionId"
size="small"
type="info"
effect="plain"
class="task-table-exec-tag"
:title="row.executionName || '切换到该执行'"
@click.stop="emit('select-execution', row.executionId)"
>
{{ row.executionName || '未命名执行' }}
</ElTag>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="crossExecutionMode" label="角色" width="80" align="center">
<template #default="{ row }">
<ElTag v-if="getRoleLabel(row).type" :type="getRoleLabel(row).type" size="small" effect="light">
{{ getRoleLabel(row).label }}
</ElTag>
<span v-else class="task-table-role-mute">{{ getRoleLabel(row).label }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
@@ -94,26 +139,29 @@ function handleSizeChange(pageSize: number) {
<DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="row.priority" />
</template>
</ElTableColumn>
<ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
<ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn>
<ElTableColumn label="父任务" min-width="140" show-overflow-tooltip>
<ElTableColumn v-if="!crossExecutionMode" label="父任务" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn>
<ElTableColumn label="进度" width="160">
<ElTableColumn v-if="!crossExecutionMode" label="进度" width="160">
<template #default="{ row }">
<div class="task-table-progress">
<ElProgress :percentage="row.progressRate" :stroke-width="18" text-inside />
</div>
</template>
</ElTableColumn>
<ElTableColumn label="计划周期" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}</template>
<ElTableColumn :label="crossExecutionMode ? '计划完成' : '计划周期'" min-width="190" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="crossExecutionMode">{{ row.plannedEndDate || '--' }}</span>
<span v-else>{{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="实际周期" min-width="190" show-overflow-tooltip>
<ElTableColumn v-if="!crossExecutionMode" label="实际周期" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateRange(row.actualStartDate, row.actualEndDate) }}</template>
</ElTableColumn>
<ElTableColumn label="最近更新" width="170">
<ElTableColumn v-if="!crossExecutionMode" label="最近更新" width="170">
<template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="210" fixed="right" align="center" class-name="task-operate-column">
@@ -157,6 +205,20 @@ function handleSizeChange(pageSize: number) {
}
}
.task-table-exec-tag {
cursor: pointer;
max-width: 100%;
}
.task-table-exec-tag:hover {
opacity: 0.85;
}
.task-table-role-mute {
color: rgb(148 163 184);
font-size: 12px;
}
.task-table-progress {
padding: 0 8px;
}

View File

@@ -258,6 +258,19 @@ export function getTaskAssigneeActionTagType(actionType: TaskAssigneeActionType
return getStatusTagType('taskAssigneeMember', actionType);
}
/** 任务工作区的搜索模型:在 ProjectTaskSearchParams 基础上加入跨执行视角专用字段 */
export interface TaskWorkspaceSearchModel {
keyword?: string;
statusCode?: string;
priority?: string;
ownerId?: string;
/** 跨执行视角下:限定到某个执行 */
executionId?: string;
/** 截止时间快速选项;仅跨执行视角下生效(新接口入参),单执行视角下后端老接口不支持,前端不下传 */
dueRange?: 'overdue' | 'today' | 'thisWeek';
updateTime?: string[];
}
/** worklog 提交后通过 emit 链路向上透传的 payloadworkspace 据此判定是否触发完成级联 */
export interface WorklogChangedPayload {
/** 本次操作类型create / edit / delete */

View File

@@ -2,19 +2,36 @@ import type { Component } from 'vue';
import { markRaw, shallowRef } from 'vue';
export type WorkbenchModuleKey =
| 'kpi'
| 'myTodo'
| 'myTask'
| 'myRequirement'
| 'myProject'
| 'activity'
| 'shortcut'
| 'teamTodo'
| 'projectHealth'
| 'progressChart'
| 'favorite';
// 保留:现有 key 沿用(避免影响线上用户布局存储)
| 'myTodo' // A1 · 我的待办
| 'myRequirement' // B9 · 我的需求
| 'myProject' // B7 · 我参与的项目
| 'shortcut' // E19 · 快捷入口
| 'projectHealth' // C15 · 产品 / 项目健康度
| 'favorite' // E21 · 我的收藏 / 关注
// 重构key 沿用,组件内容重写)
| 'myTask' // A2 · 我的今日(原"我的任务"
| 'teamTodo' // C11 · 团队任务看板(原"团队待办汇总"
// 新增 16 个(蓝图 2026-05-22
| 'myTicket' // A3 · 我负责的工单(工单待开发,先 mock
| 'mentions' // A4 · @我的提及
| 'approval' // A5 · 待审批(管理者)
| 'worklogReminder' // A6 · 工时填报提醒(动作型)
| 'myExecution' // B8 · 我负责的执行
| 'personalItem' // B10 · 我的个人事项
| 'projectSnapshot' // F23 · 项目深度快照(对象快照 / pin
| 'productSnapshot' // F24 · 产品深度快照(对象快照 / pin
| 'teamWorklog' // C12 · 团队工时分布(管理者)
| 'teamLoad' // C13 · 团队负载(管理者)
| 'riskAlert' // C14 · 风险预警(管理者)
| 'myWeekWorklog' // D16 · 我的本周工时
| 'myCompletionRate' // D17 · 我的完成率
| 'ticketSla' // D18 · 工单 SLA 总览(管理者 + 工单待开发)
| 'recentVisit' // E20 · 最近访问
| 'noticeNotification'; // E22 · 公告 + 通知摘要
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool';
// 扩展action动作型 widget、snapshot对象快照型 widget需 pin 一个对象)
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot';
export type WorkbenchColumnId = 'left' | 'right';
export interface WorkbenchModuleMeta {
@@ -31,16 +48,7 @@ export interface WorkbenchModuleMeta {
const placeholder = markRaw({ render: () => null });
const registry: WorkbenchModuleMeta[] = [
{
key: 'kpi',
component: placeholder,
displayName: 'KPI 速览',
icon: 'mdi:view-dashboard-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 1
},
// === 保留 6 个:默认布局沿用原配置,用户线上布局 0 影响 ===
{
key: 'myTodo',
component: placeholder,
@@ -49,17 +57,17 @@ const registry: WorkbenchModuleMeta[] = [
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 2
defaultOrder: 1
},
{
key: 'myTask',
component: placeholder,
displayName: '我的任务',
icon: 'mdi:checkbox-marked-circle-outline',
displayName: '我的今日',
icon: 'mdi:calendar-check-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 3
defaultOrder: 2
},
{
key: 'myRequirement',
@@ -69,7 +77,7 @@ const registry: WorkbenchModuleMeta[] = [
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 4
defaultOrder: 3
},
{
key: 'myProject',
@@ -81,16 +89,6 @@ const registry: WorkbenchModuleMeta[] = [
defaultColumn: 'right',
defaultOrder: 1
},
{
key: 'activity',
component: placeholder,
displayName: '最近动态',
icon: 'mdi:timeline-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 2
},
{
key: 'shortcut',
component: placeholder,
@@ -99,47 +97,199 @@ const registry: WorkbenchModuleMeta[] = [
category: 'tool',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 3
},
{
key: 'teamTodo',
component: placeholder,
displayName: '团队待办汇总',
icon: 'mdi:account-group-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 4
defaultOrder: 2
},
{
key: 'projectHealth',
component: placeholder,
displayName: '项目健康度',
displayName: '产品 / 项目健康度',
icon: 'mdi:heart-pulse',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 5
defaultOrder: 10
},
{
key: 'progressChart',
key: 'teamTodo',
component: placeholder,
displayName: '跨项目进度图',
icon: 'mdi:chart-bar',
displayName: '团队任务看板',
icon: 'mdi:view-column-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 6
defaultOrder: 11
},
{
key: 'favorite',
component: placeholder,
displayName: '我的收藏',
displayName: '我的收藏 / 关注',
icon: 'mdi:star-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 7
defaultOrder: 30
},
// === 新增 16 个:默认全部 hidden进 widget 库待用户挑(避免一上来挤爆工作台) ===
{
key: 'myTicket',
component: placeholder,
displayName: '我负责的工单',
icon: 'mdi:ticket-confirmation-outline',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 20
},
{
key: 'mentions',
component: placeholder,
displayName: '@我的提及',
icon: 'mdi:at',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 21
},
{
key: 'approval',
component: placeholder,
displayName: '待审批',
icon: 'mdi:checkbox-multiple-marked-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 22
},
{
key: 'worklogReminder',
component: placeholder,
displayName: '工时填报提醒',
icon: 'mdi:timer-sand',
category: 'action',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 20
},
{
key: 'myExecution',
component: placeholder,
displayName: '我负责的执行',
icon: 'mdi:flag-checkered',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 23
},
{
key: 'personalItem',
component: placeholder,
displayName: '我的个人事项',
icon: 'mdi:format-list-checks',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 24
},
{
key: 'projectSnapshot',
component: placeholder,
displayName: '项目深度快照',
icon: 'mdi:image-area',
category: 'snapshot',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 40
},
{
key: 'productSnapshot',
component: placeholder,
displayName: '产品深度快照',
icon: 'mdi:image-area-close',
category: 'snapshot',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 41
},
{
key: 'teamWorklog',
component: placeholder,
displayName: '团队工时分布',
icon: 'mdi:chart-bar',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 12
},
{
key: 'teamLoad',
component: placeholder,
displayName: '团队负载',
icon: 'mdi:scale-balance',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 13
},
{
key: 'riskAlert',
component: placeholder,
displayName: '风险预警',
icon: 'mdi:alert-octagon-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 14
},
{
key: 'myWeekWorklog',
component: placeholder,
displayName: '我的本周工时',
icon: 'mdi:chart-line',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 25
},
{
key: 'myCompletionRate',
component: placeholder,
displayName: '我的完成率',
icon: 'mdi:chart-donut',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 26
},
{
key: 'ticketSla',
component: placeholder,
displayName: '工单 SLA 总览',
icon: 'mdi:timer-alert-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 15
},
{
key: 'recentVisit',
component: placeholder,
displayName: '最近访问',
icon: 'mdi:history',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 31
},
{
key: 'noticeNotification',
component: placeholder,
displayName: '公告 + 通知',
icon: 'mdi:bullhorn-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 32
}
];

View File

@@ -14,32 +14,62 @@ 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';
import WorkbenchKpi from './modules/workbench-kpi.vue';
// 保留 6 个 + 重构 2 个key 沿用)
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
import WorkbenchMyTask from './modules/workbench-my-task.vue';
import WorkbenchMyRequirement from './modules/workbench-my-requirement.vue';
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
import WorkbenchProgressChart from './modules/workbench-progress-chart.vue';
import WorkbenchFavorite from './modules/workbench-favorite.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
import WorkbenchShortcut from './modules/workbench-shortcut.vue';
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
import WorkbenchFavorite from './modules/workbench-favorite.vue';
// 新增 16 个(蓝图 2026-05-22
import WorkbenchMyTicket from './modules/workbench-my-ticket.vue';
import WorkbenchMentions from './modules/workbench-mentions.vue';
import WorkbenchApproval from './modules/workbench-approval.vue';
import WorkbenchWorklogReminder from './modules/workbench-worklog-reminder.vue';
import WorkbenchMyExecution from './modules/workbench-my-execution.vue';
import WorkbenchPersonalItem from './modules/workbench-personal-item.vue';
import WorkbenchProjectSnapshot from './modules/workbench-project-snapshot.vue';
import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue';
import WorkbenchTeamWorklog from './modules/workbench-team-worklog.vue';
import WorkbenchTeamLoad from './modules/workbench-team-load.vue';
import WorkbenchRiskAlert from './modules/workbench-risk-alert.vue';
import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue';
import WorkbenchMyCompletionRate from './modules/workbench-my-completion-rate.vue';
import WorkbenchTicketSla from './modules/workbench-ticket-sla.vue';
import WorkbenchRecentVisit from './modules/workbench-recent-visit.vue';
import WorkbenchNoticeNotification from './modules/workbench-notice-notification.vue';
defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules();
registerModuleComponent('kpi', WorkbenchKpi);
// 保留 6 个 + 重构 2 个
registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myProject', WorkbenchProjectGrid);
registerModuleComponent('activity', WorkbenchActivityPanel);
registerModuleComponent('myTask', WorkbenchMyTask);
registerModuleComponent('myRequirement', WorkbenchMyRequirement);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('progressChart', WorkbenchProgressChart);
registerModuleComponent('favorite', WorkbenchFavorite);
registerModuleComponent('myProject', WorkbenchProjectGrid);
registerModuleComponent('shortcut', WorkbenchShortcut);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('favorite', WorkbenchFavorite);
// 新增 16 个
registerModuleComponent('myTicket', WorkbenchMyTicket);
registerModuleComponent('mentions', WorkbenchMentions);
registerModuleComponent('approval', WorkbenchApproval);
registerModuleComponent('worklogReminder', WorkbenchWorklogReminder);
registerModuleComponent('myExecution', WorkbenchMyExecution);
registerModuleComponent('personalItem', WorkbenchPersonalItem);
registerModuleComponent('projectSnapshot', WorkbenchProjectSnapshot);
registerModuleComponent('productSnapshot', WorkbenchProductSnapshot);
registerModuleComponent('teamWorklog', WorkbenchTeamWorklog);
registerModuleComponent('teamLoad', WorkbenchTeamLoad);
registerModuleComponent('riskAlert', WorkbenchRiskAlert);
registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog);
registerModuleComponent('myCompletionRate', WorkbenchMyCompletionRate);
registerModuleComponent('ticketSla', WorkbenchTicketSla);
registerModuleComponent('recentVisit', WorkbenchRecentVisit);
registerModuleComponent('noticeNotification', WorkbenchNoticeNotification);
const workbench = useWorkbenchStore();
const libraryOpen = ref(false);

View File

@@ -1,157 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchActivityItems } from '../homepage';
import { workbenchActivityMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const items = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
</script>
<template>
<WorkbenchModuleCard
title="最近动态"
icon="mdi:timeline-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div v-if="items.length" class="workbench-activity__list">
<article v-for="item in items" :key="item.id" class="workbench-activity__item">
<div class="workbench-activity__rail">
<span class="workbench-activity__dot" :class="`workbench-activity__dot--${item.tone}`" />
<span class="workbench-activity__line" />
</div>
<div class="workbench-activity__body">
<div class="workbench-activity__meta">
<span class="workbench-activity__time" :title="item.timeLabel">{{ item.relativeLabel }}</span>
<span v-if="item.mentioned" class="workbench-activity__mention">@ 提醒</span>
</div>
<p class="workbench-activity__sentence">
<strong class="workbench-activity__actor">{{ item.actor }}</strong>
<span>{{ item.action }}</span>
<strong class="workbench-activity__target">{{ item.target }}</strong>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="暂无动态" :image-size="72" />
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-activity__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-activity__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.workbench-activity__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.workbench-activity__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 8px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.workbench-activity__dot--sky {
background-color: rgb(14 165 233 / 92%);
}
.workbench-activity__dot--emerald {
background-color: rgb(5 150 105 / 92%);
}
.workbench-activity__dot--amber {
background-color: rgb(217 119 6 / 92%);
}
.workbench-activity__dot--rose {
background-color: rgb(225 29 72 / 92%);
}
.workbench-activity__dot--violet {
background-color: rgb(124 58 237 / 92%);
}
.workbench-activity__line {
flex: 1;
width: 2px;
min-height: 28px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 24%));
}
.workbench-activity__item:last-child .workbench-activity__line {
opacity: 0;
}
.workbench-activity__body {
padding: 6px 14px 14px;
min-width: 0;
}
.workbench-activity__meta {
display: flex;
align-items: center;
gap: 8px;
}
.workbench-activity__time {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-activity__mention {
padding: 1px 8px;
border-radius: 999px;
background-color: rgb(237 233 254 / 96%);
color: rgb(109 40 217 / 96%);
font-size: 11px;
font-weight: 600;
}
.workbench-activity__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.workbench-activity__actor {
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.workbench-activity__target {
color: rgb(14 116 144 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchApproval' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ApprovalItem {
id: string;
title: string;
meta: string;
}
const items: ApprovalItem[] = [
{ id: 'a1', title: '赵六 申请 加入「风控引擎」项目', meta: '作为 开发成员 · 10min 前' },
{ id: 'a2', title: '钱七 申请 关闭执行「迭代 24.05」', meta: '含 2 个未完成任务 · 1h 前' },
{ id: 'a3', title: '孙八 申请 延期 3 天', meta: '任务「分片设计」 · 昨日' }
];
function approve(id: string) {
window.$message?.success(`已批准 ${id}mock`);
}
function reject(id: string) {
window.$message?.warning(`已驳回 ${id}mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="待审批"
icon="mdi:checkbox-multiple-marked-outline"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="approval-list">
<li v-for="item in items" :key="item.id" class="approval-item">
<div class="approval-body">
<div class="approval-title">{{ item.title }}</div>
<div class="approval-meta">{{ item.meta }}</div>
</div>
<div class="approval-actions">
<ElButton size="small" type="success" plain @click="approve(item.id)">批准</ElButton>
<ElButton size="small" type="danger" plain @click="reject(item.id)">驳回</ElButton>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.approval-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.approval-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.approval-body {
flex: 1;
min-width: 0;
}
.approval-title {
font-size: 13px;
font-weight: 500;
}
.approval-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.approval-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
</style>

View File

@@ -1,212 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type WorkbenchKpiCard, buildWorkbenchKpiCards } from '../homepage';
import { workbenchKpiMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchKpi' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const cards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
if (trend === 'up') return 'mdi:arrow-top-right-thin';
if (trend === 'down') return 'mdi:arrow-bottom-right-thin';
return 'mdi:minus';
}
</script>
<template>
<WorkbenchModuleCard
title="KPI 速览"
icon="mdi:view-dashboard-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<section class="workbench-kpi">
<article
v-for="card in cards"
:key="card.key"
class="workbench-kpi__card"
:class="`workbench-kpi__card--${card.tone}`"
>
<div class="workbench-kpi__card-header">
<span class="workbench-kpi__card-label">{{ card.label }}</span>
<span class="workbench-kpi__card-icon">
<SvgIcon :icon="card.icon" />
</span>
</div>
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
<span>{{ card.trendText }}</span>
</div>
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
</article>
</section>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-kpi {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.workbench-kpi__card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
min-height: 148px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 96%));
overflow: hidden;
}
.workbench-kpi__card::after {
content: '';
position: absolute;
inset: -40% -30% auto auto;
width: 160px;
height: 160px;
border-radius: 50%;
opacity: 0.55;
pointer-events: none;
}
.workbench-kpi__card--sky::after {
background: radial-gradient(circle, rgb(14 165 233 / 22%), transparent 70%);
}
.workbench-kpi__card--emerald::after {
background: radial-gradient(circle, rgb(16 185 129 / 22%), transparent 70%);
}
.workbench-kpi__card--amber::after {
background: radial-gradient(circle, rgb(245 158 11 / 22%), transparent 70%);
}
.workbench-kpi__card--rose::after {
background: radial-gradient(circle, rgb(244 63 94 / 22%), transparent 70%);
}
.workbench-kpi__card-header {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
.workbench-kpi__card-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
font-weight: 600;
}
.workbench-kpi__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
background-color: rgb(255 255 255 / 88%);
font-size: 18px;
}
.workbench-kpi__card--sky .workbench-kpi__card-icon {
color: rgb(14 116 144 / 94%);
}
.workbench-kpi__card--emerald .workbench-kpi__card-icon {
color: rgb(5 150 105 / 94%);
}
.workbench-kpi__card--amber .workbench-kpi__card-icon {
color: rgb(217 119 6 / 94%);
}
.workbench-kpi__card--rose .workbench-kpi__card-icon {
color: rgb(225 29 72 / 94%);
}
.workbench-kpi__card-value {
position: relative;
z-index: 1;
color: rgb(15 23 42 / 98%);
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.workbench-kpi__card-trend {
display: inline-flex;
align-items: center;
gap: 4px;
position: relative;
z-index: 1;
width: fit-content;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workbench-kpi__card-trend--up {
color: rgb(5 150 105 / 96%);
background-color: rgb(236 253 245 / 96%);
}
.workbench-kpi__card-trend--down {
color: rgb(225 29 72 / 96%);
background-color: rgb(255 241 242 / 96%);
}
.workbench-kpi__card-trend--flat {
color: rgb(100 116 139 / 94%);
background-color: rgb(241 245 249 / 96%);
}
.workbench-kpi__card-trend-icon {
font-size: 14px;
}
.workbench-kpi__card-hint {
margin: 0;
position: relative;
z-index: 1;
color: rgb(100 116 139 / 88%);
font-size: 12px;
line-height: 1.6;
}
@media (width <= 1280px) {
.workbench-kpi {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-kpi {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMentions' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MentionItem {
id: string;
fromName: string;
fromAvatar: string;
context: string;
timeLabel: string;
unread: boolean;
}
const items: MentionItem[] = [
{
id: 'm1',
fromName: '李四',
fromAvatar: '李',
context: '在 任务「分片设计评审」中 @ 了你',
timeLabel: '2h 前 · 评审建议',
unread: true
},
{
id: 'm2',
fromName: '张三',
fromAvatar: '张',
context: '在 执行「迭代 24.05」中 @ 了你',
timeLabel: '昨日 · 关闭确认',
unread: true
},
{
id: 'm3',
fromName: '王五',
fromAvatar: '王',
context: '在 需求「多币种支持」中 @ 了你',
timeLabel: '3 天前',
unread: true
}
];
const unreadCount = items.filter(i => i.unread).length;
</script>
<template>
<WorkbenchModuleCard
title="@我的提及"
icon="mdi:at"
:badge-count="unreadCount"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="mention-list">
<li v-for="item in items" :key="item.id" class="mention-item">
<span class="mention-avatar">{{ item.fromAvatar }}</span>
<div class="mention-body">
<div class="mention-text">
<strong>{{ item.fromName }}</strong>
{{ item.context }}
</div>
<div class="mention-meta">{{ item.timeLabel }}</div>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.mention-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.mention-item {
display: flex;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.mention-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 13px;
}
.mention-body {
min-width: 0;
flex: 1;
}
.mention-text {
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.mention-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import type { WorkbenchColumnId, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
import type {
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
interface Props {
modelValue: boolean;
@@ -10,6 +14,22 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
}>();
const categoryLabel: Record<WorkbenchModuleCategory, string> = {
personal: '个人',
manager: '管理者',
tool: '工具',
action: '动作',
snapshot: '快照'
};
const categoryTagType: Record<WorkbenchModuleCategory, 'info' | 'warning' | 'success' | 'primary' | 'danger'> = {
personal: 'info',
manager: 'warning',
tool: 'info',
action: 'success',
snapshot: 'primary'
};
</script>
<template>
@@ -32,8 +52,8 @@ const emit = defineEmits<{
>
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
<ElTag size="small" :type="meta.category === 'manager' ? 'warning' : 'info'">
{{ meta.category === 'manager' ? '管理者' : meta.category === 'tool' ? '工具' : '个人' }}
<ElTag size="small" :type="categoryTagType[meta.category]">
{{ categoryLabel[meta.category] }}
</ElTag>
</li>
</ul>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyCompletionRate' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const rate = 72; // %
const teamAvg = 65;
const total = 25;
const completed = 18;
const onTime = 13;
const overdue = 5;
const diff = rate - teamAvg;
</script>
<template>
<WorkbenchModuleCard
title="我的完成率"
icon="mdi:chart-donut"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cr-wrap">
<div class="donut" :style="{ '--p': rate } as any">
<div class="donut-inner">
<b>{{ rate }}%</b>
<span>按时完成</span>
</div>
</div>
<div class="cr-info">
<div class="cr-line">团队均值 {{ teamAvg }}%</div>
<div class="cr-line">
任务完成
<b>{{ completed }}</b>
/ {{ total }}
</div>
<div class="cr-line">
按时
<b>{{ onTime }}</b>
· 逾期
<b>{{ overdue }}</b>
</div>
<div class="cr-diff" :class="diff >= 0 ? 'text-success' : 'text-danger'">
{{ diff >= 0 ? `高于团队均值 +${diff}%` : `低于团队均值 ${diff}%` }}
</div>
</div>
</div>
<div class="cr-hint">统计近 30 </div>
</WorkbenchModuleCard>
</template>
<style scoped>
.cr-wrap {
display: flex;
align-items: center;
gap: 16px;
}
.donut {
width: 90px;
height: 90px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.donut-inner {
width: 68px;
height: 68px;
border-radius: 50%;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.donut-inner b {
font-size: 18px;
color: var(--el-text-color-primary);
}
.donut-inner span {
font-size: 10px;
color: var(--el-text-color-secondary);
}
.cr-info {
flex: 1;
font-size: 13px;
}
.cr-line {
margin: 3px 0;
}
.cr-line b {
font-weight: 700;
}
.cr-diff {
margin-top: 4px;
font-size: 12px;
}
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
.cr-hint {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ExecutionRow {
id: string;
name: string;
project: string;
done: number;
total: number;
progress: number;
statusLabel: string;
overdue: boolean;
}
const rows: ExecutionRow[] = [
{
id: 'e1',
name: '迭代 24.05',
project: '商城 V2 升级',
done: 12,
total: 15,
progress: 80,
statusLabel: '03 天后结束',
overdue: false
},
{
id: 'e2',
name: '关键路径优化',
project: '风控引擎',
done: 3,
total: 8,
progress: 38,
statusLabel: '已逾期 2 天',
overdue: true
},
{
id: 'e3',
name: '多币种支持',
project: '收银台',
done: 5,
total: 7,
progress: 71,
statusLabel: '在期内',
overdue: false
}
];
</script>
<template>
<WorkbenchModuleCard
title="我负责的执行"
icon="mdi:flag-checkered"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="exec-list">
<li v-for="row in rows" :key="row.id" class="exec-item">
<div class="exec-head">
<div class="exec-name">{{ row.name }}</div>
<div class="exec-progress">{{ row.progress }}%</div>
</div>
<div class="exec-meta">
<span>{{ row.project }}</span>
<span class="exec-sep">·</span>
<span>{{ row.done }} / {{ row.total }} 任务完成</span>
<span class="exec-sep">·</span>
<span :class="{ 'exec-overdue': row.overdue }">{{ row.statusLabel }}</span>
</div>
<div class="exec-bar">
<div class="exec-bar-inner" :class="{ 'is-overdue': row.overdue }" :style="{ width: `${row.progress}%` }" />
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.exec-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.exec-item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.exec-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.exec-name {
font-weight: 600;
font-size: 14px;
}
.exec-progress {
font-size: 16px;
font-weight: 700;
color: var(--el-color-primary);
}
.exec-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.exec-sep {
margin: 0 6px;
color: var(--el-text-color-placeholder);
}
.exec-overdue {
color: var(--el-color-danger);
font-weight: 600;
}
.exec-bar {
height: 6px;
border-radius: 999px;
background: var(--el-fill-color);
overflow: hidden;
}
.exec-bar-inner {
height: 100%;
background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-primary-light-3));
transition: width 240ms ease;
}
.exec-bar-inner.is-overdue {
background: linear-gradient(90deg, var(--el-color-danger), var(--el-color-danger-light-3));
}
</style>

View File

@@ -1,105 +1,107 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { type WorkbenchMyTaskBucket, buildWorkbenchMyTaskItems, filterWorkbenchMyTaskItems } from '../homepage';
import { workbenchMyTaskMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyTask' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void; (e: 'refresh'): void }>();
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const { routerPushByKey } = useRouterPush();
const allItems = computed(() => buildWorkbenchMyTaskItems(workbenchMyTaskMock));
const bucket = ref<WorkbenchMyTaskBucket>('today');
const visibleItems = computed(() => filterWorkbenchMyTaskItems(allItems.value, bucket.value));
function handleClickItem() {
routerPushByKey('project_list');
interface FocusItem {
id: string;
title: string;
done: boolean;
}
function handleRefresh() {
window.$message?.success('已刷新v1 mock');
}
const items = ref<FocusItem[]>([
{ id: 'f1', title: '提交昨日工时', done: true },
{ id: 'f2', title: '登录页 SSO 改造 · 18:00 截止', done: false },
{ id: 'f3', title: '迭代 24.05 关闭 · 跟交付 / QA 确认遗留', done: false }
]);
const doneCount = computed(() => items.value.filter(i => i.done).length);
const totalCount = computed(() => items.value.length);
const todayHours = 3.5;
const targetHours = 8;
</script>
<template>
<WorkbenchModuleCard
title="我的任务"
icon="mdi:checkbox-marked-circle-outline"
:badge-count="visibleItems.length"
title="我的今日"
icon="mdi:calendar-check-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@navigate="handleClickItem"
@refresh="handleRefresh"
>
<ElTabs v-model="bucket" class="my-task-tabs">
<ElTabPane label="今日" name="today" />
<ElTabPane label="本周" name="week" />
<ElTabPane label="逾期" name="overdue" />
<ElTabPane label="全部" name="all" />
</ElTabs>
<ul v-if="visibleItems.length" class="my-task-list">
<li v-for="item in visibleItems" :key="item.id" class="my-task-item" @click="handleClickItem">
<ElTag size="small" :type="item.overdue ? 'danger' : 'info'">{{ item.statusLabel }}</ElTag>
<span class="my-task-title">{{ item.title }}</span>
<span class="my-task-meta">{{ item.projectName }} · {{ item.executionName }}</span>
<span class="my-task-deadline" :class="{ overdue: item.overdue }">{{ item.deadlineLabel }}</span>
<div class="today-head">
<span class="today-progress">{{ doneCount }} / {{ totalCount }} 已完成</span>
</div>
<ul class="today-list">
<li v-for="item in items" :key="item.id" class="today-row">
<ElCheckbox v-model="item.done" />
<span class="today-text" :class="{ 'today-done': item.done }">{{ item.title }}</span>
</li>
</ul>
<ElEmpty v-else description="暂无任务" :image-size="60" />
<div class="today-foot">
当日累计工时
<b>{{ todayHours }}h</b>
/ {{ targetHours }}h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.my-task-tabs {
margin-bottom: 4px;
.today-head {
display: flex;
justify-content: flex-end;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.my-task-list {
.today-progress {
padding: 2px 8px;
background: var(--el-fill-color-lighter);
border-radius: 999px;
}
.today-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.my-task-item {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: 2px 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
background: var(--el-fill-color-lighter);
transition: background 120ms;
.today-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.my-task-item:hover {
background: var(--el-color-primary-light-9);
.today-row:last-child {
border-bottom: none;
}
.my-task-title {
font-weight: 500;
.today-text {
font-size: 13px;
}
.my-task-meta {
grid-column: 2 / 3;
.today-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
.today-foot {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
}
.my-task-deadline {
grid-column: 3 / 4;
grid-row: 1 / 3;
align-self: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.my-task-deadline.overdue {
color: var(--el-color-danger);
font-weight: 600;
.today-foot b {
color: var(--el-text-color-primary);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyTicket' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
type Tone = 'rose' | 'amber' | 'slate';
interface TicketRow {
id: string;
title: string;
product: string;
priorityLabel: string;
priorityTone: Tone;
slaLabel: string;
slaTone: Tone;
}
const rows: TicketRow[] = [
{
id: 't1',
title: '商户后台登录异常',
product: '商户后台',
priorityLabel: '高',
priorityTone: 'rose',
slaLabel: '超时 2h',
slaTone: 'rose'
},
{
id: 't2',
title: '报表导出失败',
product: '数据中心',
priorityLabel: '中',
priorityTone: 'amber',
slaLabel: '剩 4h',
slaTone: 'amber'
},
{
id: 't3',
title: '移动端推送延迟',
product: '用户中心',
priorityLabel: '低',
priorityTone: 'slate',
slaLabel: '剩 2 天',
slaTone: 'slate'
}
];
</script>
<template>
<WorkbenchModuleCard
title="我负责的工单"
icon="mdi:ticket-confirmation-outline"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="warning" :closable="false" class="pending-hint">
工单业务暂未上线当前为 mock 数据正式接口落地后接通
</ElAlert>
<ul class="ticket-list">
<li v-for="row in rows" :key="row.id" class="ticket-item">
<span class="ticket-priority" :class="`tone-${row.priorityTone}`">{{ row.priorityLabel }}</span>
<div class="ticket-body">
<div class="ticket-title">{{ row.title }}</div>
<div class="ticket-meta">{{ row.product }}</div>
</div>
<span class="ticket-sla" :class="`tone-${row.slaTone}`">{{ row.slaLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.pending-hint {
margin-bottom: 10px;
}
.ticket-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.ticket-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.ticket-priority {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
.ticket-body {
min-width: 0;
}
.ticket-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ticket-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.ticket-sla {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.tone-rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.tone-amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.tone-slate {
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
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 }>();
const days = ['周一', '周二', '周三', '周四', '周五', '今日'];
const hoursPerDay = [4, 7, 6, 8, 7.5, 0]; // 今日为 0未填报
const total = hoursPerDay.reduce((s, h) => s + h, 0);
const target = 40;
const todayProgress = 32.5; // 截至当前小时数(含历史 + 今日已提交部分)
const delta = todayProgress - target * 0.75; // 目标按周 75% 进度
const deltaText = delta >= 0 ? `领先目标 +${delta.toFixed(1)}h` : `落后目标 ${delta.toFixed(1)}h`;
const deltaClass = delta >= 0 ? 'text-success' : 'text-danger';
// 折线坐标计算(基于 200x80 viewBox
const padX = 10;
const padY = 10;
const width = 200;
const height = 80;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const maxY = 10;
const points = hoursPerDay.map((h, i) => {
const x = padX + (i / (hoursPerDay.length - 1)) * innerW;
const y = padY + innerH - (h / maxY) * innerH;
return { x: Number(x.toFixed(1)), y: Number(y.toFixed(1)) };
});
const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
</script>
<template>
<WorkbenchModuleCard
title="我的本周工时"
icon="mdi:chart-line"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<svg viewBox="0 0 200 80" preserveAspectRatio="none" class="spark">
<line x1="0" y1="20" x2="200" y2="20" stroke="var(--el-border-color-lighter)" stroke-dasharray="3,3" />
<polyline :points="polyline" fill="none" stroke="var(--el-color-primary)" stroke-width="2" />
<circle v-for="(p, i) in points" :key="i" :cx="p.x" :cy="p.y" r="3" fill="var(--el-color-primary)" />
</svg>
<div class="ww-x">
<span v-for="d in days" :key="d">{{ d }}</span>
</div>
<div class="ww-foot">
<span>
累计
<b>{{ todayProgress }}h</b>
/ {{ target }}h
</span>
<span :class="deltaClass">{{ deltaText }}</span>
</div>
<div class="ww-hint">本周总和含今日{{ total.toFixed(1) }}h</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.spark {
width: 100%;
height: 80px;
display: block;
}
.ww-x {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.ww-foot {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-top: 10px;
}
.ww-foot b {
font-weight: 700;
}
.ww-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
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 Row {
id: string;
title: string;
timeLabel: string;
}
const notices: Row[] = [
{ 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: Row[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前' },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前' },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日' }
];
</script>
<template>
<WorkbenchModuleCard
title="公告 + 通知"
icon="mdi:bullhorn-outline"
:badge-count="notifications.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<div class="nn-col">
<div class="nn-h">📢 公告</div>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
</li>
</ul>
</div>
<div class="nn-col">
<div class="nn-h">🔔 系统通知未读 {{ notifications.length }}</div>
<ul class="nn-list">
<li v-for="row in notifications" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
</li>
</ul>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.nn-col {
min-width: 0;
}
.nn-h {
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 600;
margin-bottom: 6px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
}
.nn-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
gap: 8px;
}
.nn-list li:last-child {
border-bottom: none;
}
.nn-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nn-time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
@media (width <= 1280px) {
.nn-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchPersonalItem' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ItemRow {
id: string;
title: string;
done: boolean;
}
const items = ref<ItemRow[]>([
{ id: 'p1', title: '周会准备 · 跨境支付方案 PPT', done: false },
{ id: 'p2', title: '整理 Q2 OKR 复盘材料', done: false },
{ id: 'p3', title: '回复采购系统迁移邮件', done: true },
{ id: 'p4', title: '技术分享话题征集', done: false },
{ id: 'p5', title: '新人 Onboarding · 文档整理', done: false }
]);
</script>
<template>
<WorkbenchModuleCard
title="我的个人事项"
icon="mdi:format-list-checks"
:badge-count="items.filter(i => !i.done).length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="item-list">
<li v-for="item in items" :key="item.id" class="item-row">
<ElCheckbox v-model="item.done" />
<span class="item-text" :class="{ 'item-done': item.done }">{{ item.title }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.item-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.item-row:last-child {
border-bottom: none;
}
.item-text {
font-size: 13px;
}
.item-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref } from 'vue';
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 }>();
interface ProductOption {
id: string;
name: string;
status: string;
activeRequirement: number;
relatedProject: number;
memberCount: number;
blockCount: number;
myRole: string;
requirement: { pending: number; inDev: number; inAccept: number; done: number };
activities: Array<{ id: string; title: string; timeLabel: string }>;
}
const products: ProductOption[] = [
{
id: 'pr1',
name: '商户后台',
status: '迭代中',
activeRequirement: 23,
relatedProject: 3,
memberCount: 12,
blockCount: 2,
myRole: '产品负责人',
requirement: { pending: 6, inDev: 8, inAccept: 5, done: 4 },
activities: [
{ id: 'a1', title: '需求「多币种支持」评审通过', timeLabel: '2h 前' },
{ id: 'a2', title: '需求「订单流水查询」拆出 5 个任务', timeLabel: '昨日' },
{ id: 'a3', title: '需求「报表升级」状态:待评审 → 开发中', timeLabel: '2 天前' }
]
},
{
id: 'pr2',
name: '收银台',
status: '迭代中',
activeRequirement: 8,
relatedProject: 2,
memberCount: 5,
blockCount: 0,
myRole: '协作产品',
requirement: { pending: 2, inDev: 3, inAccept: 2, done: 1 },
activities: [
{ id: 'a4', title: '需求「多币种支持」开发中', timeLabel: '1h 前' },
{ id: 'a5', title: '汇率服务接入排期确认', timeLabel: '昨日' }
]
}
];
const pinnedId = ref(products[0].id);
const pinned = ref(products[0]);
function onChange(id: string) {
const found = products.find(p => p.id === id);
if (found) pinned.value = found;
}
</script>
<template>
<WorkbenchModuleCard
title="产品深度快照"
icon="mdi:image-area-close"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div class="ps-overview">
<ElTag type="success" effect="dark" size="small">{{ pinned.status }}</ElTag>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ pinned.activeRequirement }}</b>
<span>活跃需求</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.relatedProject }}</b>
<span>关联项目</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.memberCount }}</b>
<span>团队成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-warn': pinned.blockCount > 0 }">{{ pinned.blockCount }}</b>
<span>阻塞</span>
</div>
</div>
</div>
<div class="ps-sub">我的角色{{ pinned.myRole }}</div>
<div class="ps-section-title">📋 需求状态分布</div>
<div class="ps-req">
<div class="ps-req-cell">
<b>{{ pinned.requirement.pending }}</b>
<span>待评审</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.inDev }}</b>
<span>开发中</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.inAccept }}</b>
<span>验收中</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.done }}</b>
<span>已发布</span>
</div>
</div>
<div class="ps-section-title">🔄 近期动态</div>
<ul class="ps-act">
<li v-for="a in pinned.activities" :key="a.id">
<span class="ps-act-title">{{ a.title }}</span>
<span class="ps-act-time">{{ a.timeLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 180px;
}
.ps-overview {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
}
.ps-kpi b.is-warn {
color: var(--el-color-warning);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-req {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ps-req-cell {
background: var(--el-fill-color-lighter);
border-radius: 6px;
padding: 8px;
text-align: center;
}
.ps-req-cell b {
display: block;
font-size: 18px;
font-weight: 700;
}
.ps-req-cell span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-act {
list-style: none;
margin: 0;
padding: 0;
}
.ps-act li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
}
.ps-act li:last-child {
border-bottom: none;
}
.ps-act-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.ps-act-time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

@@ -1,78 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import * as echarts from 'echarts';
import { buildWorkbenchProgressBars } from '../homepage';
import { workbenchProgressChartMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
const props = withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
defineOptions({ name: 'WorkbenchProgressChart' });
const bars = computed(() => buildWorkbenchProgressBars(workbenchProgressChartMock));
const chartEl = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
function render() {
if (!chartEl.value) return;
if (!chart) chart = echarts.init(chartEl.value);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 20, right: 20, top: 20, bottom: 40, containLabel: true },
xAxis: { type: 'category', data: bars.value.map(b => b.projectName), axisLabel: { interval: 0 } },
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{
type: 'bar',
data: bars.value.map(b => b.weekCompletionRate),
itemStyle: { color: '#2563eb', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', formatter: '{c}%' }
}
]
});
}
onMounted(render);
watch(() => bars.value, render, { deep: true });
watch(
() => props.collapsed,
v => {
if (!v) setTimeout(render, 0);
}
);
onUnmounted(() => {
chart?.dispose();
chart = null;
});
</script>
<template>
<WorkbenchModuleCard
title="跨项目进度图"
icon="mdi:chart-bar"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div ref="chartEl" class="progress-chart" />
</WorkbenchModuleCard>
</template>
<style scoped>
.progress-chart {
width: 100%;
height: 220px;
}
</style>

View File

@@ -12,21 +12,37 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
// 产品维度 mock蓝图 C15 要求扩到产品维度,未来后端接口落地后接通)
interface ProductHealth {
id: string;
name: string;
health: 'green' | 'yellow' | 'red';
healthLabel: string;
activeRequirement: number;
blockCount: number;
}
const productCards: ProductHealth[] = [
{ id: 'pr1', name: '商户后台', health: 'green', healthLabel: '良好', activeRequirement: 15, blockCount: 0 },
{ id: 'pr2', name: '收银台', health: 'green', healthLabel: '良好', activeRequirement: 8, blockCount: 0 },
{ id: 'pr3', name: '用户中心', health: 'yellow', healthLabel: '关注', activeRequirement: 11, blockCount: 1 }
];
</script>
<template>
<WorkbenchModuleCard
title="项目健康度"
title="产品 / 项目健康度"
icon="mdi:heart-pulse"
:badge-count="cards.length"
:badge-count="projectCards.length + productCards.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="section-title">项目</div>
<div class="health-list">
<div v-for="card in cards" :key="card.projectId" class="health-card">
<div v-for="card in projectCards" :key="card.projectId" class="health-card">
<div class="health-card__ring" :class="`is-${card.health}`">
<span>{{ card.healthLabel }}</span>
</div>
@@ -40,10 +56,36 @@ const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHe
</div>
</div>
</div>
<div class="section-title section-title--gap">产品</div>
<div class="health-list">
<div v-for="card in productCards" :key="card.id" class="health-card">
<div class="health-card__ring" :class="`is-${card.health}`">
<span>{{ card.healthLabel }}</span>
</div>
<div class="health-card__body">
<div class="health-card__name">{{ card.name }}</div>
<div class="health-card__meta">
<ElTag size="small">活跃需求 {{ card.activeRequirement }}</ElTag>
<ElTag v-if="card.blockCount > 0" size="small" type="warning">阻塞 {{ card.blockCount }}</ElTag>
<ElTag v-else size="small" type="success">无阻塞</ElTag>
</div>
</div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.section-title--gap {
margin-top: 14px;
}
.health-list {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectSnapshot' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ProjectOption {
id: string;
name: string;
progress: number;
executionCount: number;
taskCount: number;
memberCount: number;
overdueCount: number;
remainingDays: number;
myRole: string;
milestones: Array<{ id: string; title: string; timeLabel: string; tone: 'amber' | 'slate' }>;
members: Array<{ name: string; load: number; level: 'ok' | 'warn' | 'over' }>;
}
const projects: ProjectOption[] = [
{
id: 'p1',
name: '商城 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: '风控引擎接入',
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' }
]
}
];
const pinnedId = ref(projects[0].id);
const pinned = ref(projects[0]);
function onChange(id: string) {
const found = projects.find(p => p.id === id);
if (found) pinned.value = found;
}
</script>
<template>
<WorkbenchModuleCard
title="项目深度快照"
icon="mdi:image-area"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': pinned.progress } as any">
<span>{{ pinned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ pinned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': pinned.overdueCount > 0 }">{{ pinned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ pinned.remainingDays }} · 我的角色{{ pinned.myRole }}</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in pinned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div v-if="pinned.myRole === '负责人'" class="ps-section-title">👥 成员负载</div>
<ul v-if="pinned.myRole === '负责人'" class="ps-members">
<li v-for="m in pinned.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>
</WorkbenchModuleCard>
</template>
<style scoped>
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 180px;
}
.ps-overview {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-ring {
width: 50px;
height: 50px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ps-ring span {
background: #fff;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.ps-kpi b.is-danger {
color: var(--el-color-danger);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
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;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
}
.ps-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.ps-bar-inner {
height: 100%;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
}
.ps-bar-inner.is-warn {
background: var(--el-color-warning);
}
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRecentVisit' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface VisitRow {
id: string;
title: string;
type: '项目' | '执行' | '产品' | '需求';
timeLabel: string;
}
const rows: VisitRow[] = [
{ id: 'v1', title: '商城 V2 升级', type: '项目', timeLabel: '2h 前' },
{ id: 'v2', title: '迭代 24.05', type: '执行', timeLabel: '今日' },
{ id: 'v3', title: '分片设计评审', type: '需求', timeLabel: '昨日' },
{ id: 'v4', title: '收银台', type: '产品', timeLabel: '2 天前' },
{ id: 'v5', title: '风控引擎接入', type: '项目', timeLabel: '3 天前' }
];
function typeTag(t: VisitRow['type']): 'primary' | 'success' | 'warning' | 'info' {
return ({ 项目: 'primary', 执行: 'success', 产品: 'warning', 需求: 'info' } as const)[t];
}
</script>
<template>
<WorkbenchModuleCard
title="最近访问"
icon="mdi:history"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="visit-list">
<li v-for="row in rows" :key="row.id" class="visit-item">
<ElTag size="small" :type="typeTag(row.type)">{{ row.type }}</ElTag>
<span class="visit-title">{{ row.title }}</span>
<span class="visit-time">{{ row.timeLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.visit-list {
list-style: none;
margin: 0;
padding: 0;
}
.visit-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.visit-item:last-child {
border-bottom: none;
}
.visit-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.visit-time {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRiskAlert' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface Stat {
n: number;
label: string;
tone: 'rose' | 'amber' | 'sky';
}
interface RiskRow {
id: string;
title: string;
owner: string;
sub: string;
tone: 'rose' | 'amber';
}
const stats: Stat[] = [
{ n: 3, label: '逾期任务', tone: 'rose' },
{ n: 2, label: '停滞执行 (>7d)', tone: 'amber' },
{ n: 2, label: '超时未关闭工单', tone: 'rose' }
];
const rows: RiskRow[] = [
{ id: 'r1', title: '缓存穿透优化', owner: '李四', sub: '逾期 2 天', tone: 'rose' },
{ id: 'r2', title: '商户后台登录异常', owner: '张三', sub: 'SLA 超 2h', tone: 'rose' },
{ id: 'r3', title: '关键路径优化', owner: '风控引擎', sub: '停滞 9 天', tone: 'amber' }
];
</script>
<template>
<WorkbenchModuleCard
title="风险预警"
icon="mdi:alert-octagon-outline"
:badge-count="stats.reduce((s, x) => s + x.n, 0)"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="risk-grid">
<div v-for="s in stats" :key="s.label" class="risk-cell" :class="`tone-${s.tone}`">
<div class="risk-n">{{ s.n }}</div>
<div class="risk-lbl">{{ s.label }}</div>
</div>
</div>
<ul class="risk-list">
<li v-for="row in rows" :key="row.id" class="risk-row">
<span class="risk-title" :class="`tone-${row.tone}`">{{ row.title }} · {{ row.owner }}</span>
<span class="risk-sub">{{ row.sub }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.risk-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.risk-cell {
padding: 10px;
border-radius: 8px;
text-align: left;
}
.risk-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.risk-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.risk-cell.tone-sky {
background: #f0f9ff;
color: #075985;
}
.risk-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.risk-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.risk-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.risk-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
}
.risk-row:last-child {
border-bottom: none;
}
.risk-title.tone-rose {
color: var(--el-color-danger);
}
.risk-title.tone-amber {
color: var(--el-color-warning);
}
.risk-sub {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
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 }>();
interface LoadRow {
name: string;
inProgress: number;
}
const rows: LoadRow[] = [
{ name: '张三', inProgress: 5 },
{ name: '李四', inProgress: 3 },
{ name: '王五', inProgress: 7 },
{ name: '赵六', inProgress: 2 },
{ name: '钱七', inProgress: 5 }
];
const MAX = 10;
function level(n: number): 'ok' | 'warn' | 'over' {
if (n >= 6) return 'over';
if (n >= 4) return 'warn';
return 'ok';
}
</script>
<template>
<WorkbenchModuleCard
title="团队负载"
icon="mdi:scale-balance"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="load-list">
<li v-for="row in rows" :key="row.name" class="load-item">
<span class="load-name">{{ row.name }}</span>
<div class="load-bar">
<div
class="load-bar-inner"
:class="`is-${level(row.inProgress)}`"
:style="{ width: `${(row.inProgress / MAX) * 100}%` }"
/>
</div>
<span class="load-n" :class="{ 'text-danger': level(row.inProgress) === 'over' }">
{{ row.inProgress }}{{ level(row.inProgress) === 'over' ? ' ⚠' : '' }}
</span>
</li>
</ul>
<div class="load-hint">阈值 6 高负载 · 4 中负载</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.load-list {
list-style: none;
margin: 0;
padding: 0;
}
.load-item {
display: grid;
grid-template-columns: 60px 1fr 50px;
align-items: center;
gap: 10px;
padding: 6px 0;
font-size: 13px;
}
.load-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.load-bar-inner {
height: 100%;
}
.load-bar-inner.is-ok {
background: var(--el-color-success);
}
.load-bar-inner.is-warn {
background: var(--el-color-warning);
}
.load-bar-inner.is-over {
background: var(--el-color-danger);
}
.load-n {
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.load-hint {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,47 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchTeamTodoRows } from '../homepage';
import { workbenchTeamTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamTodo' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const rows = computed(() => buildWorkbenchTeamTodoRows(workbenchTeamTodoMock));
interface KanbanTask {
id: string;
title: string;
priority: '高' | '中' | '低';
deadline: string;
overdue?: boolean;
}
interface MemberColumn {
name: string;
total: number;
warn?: boolean;
tasks: KanbanTask[];
more?: number;
}
const columns: MemberColumn[] = [
{
name: '张三',
total: 5,
tasks: [
{ id: 't1', title: '登录页 SSO 改造', priority: '高', deadline: '今日截止' },
{ id: 't2', title: '用户中心头像上传', priority: '中', deadline: '05-27' },
{ id: 't3', title: '登录日志埋点', priority: '低', deadline: '06-01' }
]
},
{
name: '李四',
total: 3,
tasks: [
{ id: 't4', title: '分片设计评审', priority: '中', deadline: '明日' },
{ id: 't5', title: '缓存穿透优化', priority: '高', deadline: '已逾期', overdue: true }
]
},
{
name: '王五',
total: 7,
warn: true,
tasks: [
{ id: 't6', title: '多币种支持开发', priority: '高', deadline: '05-26' },
{ id: 't7', title: '汇率服务接入', priority: '中', deadline: '05-28' }
],
more: 5
},
{
name: '赵六',
total: 2,
tasks: [
{ id: 't8', title: '风控规则配置化', priority: '中', deadline: '05-29' },
{ id: 't9', title: '规则引擎压测', priority: '低', deadline: '06-05' }
]
}
];
function priorityClass(p: KanbanTask['priority']) {
return ({ : 'tone-rose', : 'tone-amber', : 'tone-slate' } as const)[p];
}
</script>
<template>
<WorkbenchModuleCard
title="团队待办汇总"
icon="mdi:account-group-outline"
:badge-count="rows.length"
title="团队任务看板"
icon="mdi:view-column-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElTable :data="rows" stripe size="small">
<ElTableColumn prop="projectName" label="项目" min-width="120" />
<ElTableColumn prop="memberName" label="成员" width="80" />
<ElTableColumn prop="inProgress" label="进行中" width="80" align="right" />
<ElTableColumn label="逾期" width="80" align="right">
<template #default="{ row }">
<span
:style="{
color: row.overdue > 0 ? 'var(--el-color-danger)' : 'inherit',
fontWeight: row.overdue > 0 ? 600 : 'normal'
}"
>
{{ row.overdue }}
</span>
</template>
</ElTableColumn>
<ElTableColumn prop="weekDone" label="本周完成" width="100" align="right" />
</ElTable>
<div class="kanban">
<div v-for="col in columns" :key="col.name" class="kanban-col">
<div class="kanban-h">
<span>{{ col.name }}</span>
<span class="kanban-total" :class="{ 'is-warn': col.warn }">{{ col.total }}{{ col.warn ? ' ⚠' : '' }}</span>
</div>
<div v-for="task in col.tasks" :key="task.id" class="kanban-task">
<div class="kanban-title">{{ task.title }}</div>
<div class="kanban-meta">
<span class="kanban-pri" :class="priorityClass(task.priority)">{{ task.priority }}</span>
<span :class="{ 'text-danger': task.overdue }">{{ task.deadline }}</span>
</div>
</div>
<div v-if="col.more" class="kanban-more">+{{ col.more }} </div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.kanban-col {
background: var(--el-fill-color-lighter);
border-radius: 6px;
padding: 8px;
min-height: 140px;
}
.kanban-h {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 600;
}
.kanban-total {
background: var(--el-bg-color);
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
}
.kanban-total.is-warn {
color: var(--el-color-danger);
font-weight: 700;
}
.kanban-task {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 5px;
padding: 6px;
margin-bottom: 6px;
font-size: 11px;
}
.kanban-title {
font-weight: 500;
margin-bottom: 4px;
}
.kanban-meta {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-text-color-secondary);
font-size: 10px;
}
.kanban-pri {
padding: 0 4px;
border-radius: 4px;
font-weight: 700;
}
.tone-rose {
background: #fee2e2;
color: #991b1b;
}
.tone-amber {
background: #fef3c7;
color: #92400e;
}
.tone-slate {
background: #f1f5f9;
color: #475569;
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.kanban-more {
font-size: 11px;
color: var(--el-color-danger);
text-align: center;
padding: 4px;
}
@media (width <= 1280px) {
.kanban {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamWorklog' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MemberRow {
name: string;
hours: number;
}
const members: MemberRow[] = [
{ name: '张三', hours: 38 },
{ name: '李四', hours: 42 },
{ name: '王五', hours: 30 },
{ name: '赵六', hours: 48 },
{ name: '钱七', hours: 25 }
];
const maxHours = 48;
const avg = (members.reduce((s, m) => s + m.hours, 0) / members.length).toFixed(1);
const lowest = members.reduce((a, b) => (a.hours < b.hours ? a : b));
const highest = members.reduce((a, b) => (a.hours > b.hours ? a : b));
</script>
<template>
<WorkbenchModuleCard
title="团队工时分布"
icon="mdi:chart-bar"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="bars">
<div v-for="m in members" :key="m.name" class="bar-col">
<div class="bar-value">{{ m.hours }}h</div>
<div class="bar" :style="{ height: `${(m.hours / maxHours) * 100}%` }" />
</div>
</div>
<div class="bars-x">
<div v-for="m in members" :key="m.name">{{ m.name }}</div>
</div>
<div class="bars-hint">
平均 {{ avg }}h / ·
<span class="text-danger">{{ lowest.name }}</span>
工时偏低 ·
<span class="text-warn">{{ highest.name }}</span>
40h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.bars {
display: flex;
align-items: flex-end;
gap: 10px;
height: 120px;
padding: 18px 4px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
position: relative;
}
.bar-value {
position: absolute;
top: -16px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.bar {
width: 100%;
background: linear-gradient(180deg, var(--el-color-primary), var(--el-color-primary-light-7));
border-radius: 4px 4px 0 0;
min-height: 6px;
}
.bars-x {
display: flex;
gap: 10px;
margin-top: 4px;
}
.bars-x div {
flex: 1;
text-align: center;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.bars-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
}
.text-warn {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTicketSla' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const unclosed = 12;
const overtime = 3;
const willOvertime = 5;
const byPriority = { high: 4, mid: 6, low: 2 };
</script>
<template>
<WorkbenchModuleCard
title="工单 SLA 总览"
icon="mdi:timer-alert-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="warning" :closable="false" class="pending-hint">
工单业务暂未上线当前为 mock 数据正式接口落地后接通
</ElAlert>
<div class="sla-grid">
<div class="sla-cell tone-rose">
<div class="sla-n">{{ unclosed }}</div>
<div class="sla-lbl">未关闭</div>
</div>
<div class="sla-cell tone-rose">
<div class="sla-n">{{ overtime }}</div>
<div class="sla-lbl">超时</div>
</div>
<div class="sla-cell tone-amber">
<div class="sla-n">{{ willOvertime }}</div>
<div class="sla-lbl">将超时</div>
</div>
</div>
<div class="sla-hint">按优先级 {{ byPriority.high }} · {{ byPriority.mid }} · {{ byPriority.low }}</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.pending-hint {
margin-bottom: 10px;
}
.sla-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.sla-cell {
padding: 12px;
border-radius: 8px;
text-align: left;
}
.sla-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.sla-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.sla-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.sla-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.sla-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchWorklogReminder' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface DayRow {
dateLabel: string;
status: 'filled' | 'pending' | 'missing';
hours: number;
}
const today = '今日未填报';
const todayHint = '已经 14:30 了,记得填工时';
const recent: DayRow[] = [
{ dateLabel: '05-21', status: 'filled', hours: 8 },
{ dateLabel: '05-20', status: 'filled', hours: 7.5 },
{ dateLabel: '05-19', status: 'missing', hours: 0 }
];
function fillNow() {
window.$message?.info('跳转工时填报mock');
}
function fillMakeup(label: string) {
window.$message?.info(`补填 ${label} 工时mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="工时填报提醒"
icon="mdi:timer-sand"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cta">
<div class="cta-text">
<div class="cta-title">{{ today }}</div>
<div class="cta-hint">{{ todayHint }}</div>
</div>
<ElButton type="primary" size="small" @click="fillNow">立即填报</ElButton>
</div>
<ul class="recent">
<li v-for="row in recent" :key="row.dateLabel" class="recent-item">
<span class="recent-date">{{ row.dateLabel }}</span>
<span v-if="row.status === 'filled'" class="recent-hours">已填 {{ row.hours }}h</span>
<ElButton v-else size="small" type="danger" link @click="fillMakeup(row.dateLabel)">补填</ElButton>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-dark-2));
color: #fff;
margin-bottom: 10px;
}
.cta-text {
min-width: 0;
}
.cta-title {
font-size: 14px;
font-weight: 700;
}
.cta-hint {
font-size: 12px;
opacity: 0.9;
margin-top: 2px;
}
.recent {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 6px;
}
.recent-item:hover {
background: var(--el-fill-color-lighter);
}
.recent-date {
font-size: 13px;
}
.recent-hours {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>