refactor(project): 重构项目执行模块组件结构和数据管理
- 移除 execution-list-panel.vue 组件并将功能整合到执行区域 - 新增 execution-section.vue 组件替代原有的列表面板 - 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用 - 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理 - 添加跨执行任务状态统计接口调用和数据处理逻辑 - 重构执行状态筛选和任务创建权限判断逻辑 - 更新执行选择、搜索和重置功能的事件处理方式 - 调整页面布局结构,优化左右分栏的内容组织方式 - 完善执行详情获取和状态操作的业务流程 - 优化执行分配和状态变更的异步处理机制
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。
|
||||
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403,UI 应自动切回"我的"。
|
||||
*/
|
||||
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) {
|
||||
|
||||
82
src/typings/api/project.d.ts
vendored
82
src/typings/api/project.d.ts
vendored
@@ -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(不传也走 mine);all 必须有 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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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[] {
|
||||
});
|
||||
}
|
||||
|
||||
// 状态推进按钮完全依赖 availableActions(owner-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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 链路向上透传的 payload;workspace 据此判定是否触发完成级联 */
|
||||
export interface WorklogChangedPayload {
|
||||
/** 本次操作类型:create / edit / delete */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
94
src/views/workbench/modules/workbench-approval.vue
Normal file
94
src/views/workbench/modules/workbench-approval.vue
Normal 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>
|
||||
@@ -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>
|
||||
121
src/views/workbench/modules/workbench-mentions.vue
Normal file
121
src/views/workbench/modules/workbench-mentions.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
122
src/views/workbench/modules/workbench-my-completion-rate.vue
Normal file
122
src/views/workbench/modules/workbench-my-completion-rate.vue
Normal 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>
|
||||
146
src/views/workbench/modules/workbench-my-execution.vue
Normal file
146
src/views/workbench/modules/workbench-my-execution.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
147
src/views/workbench/modules/workbench-my-ticket.vue
Normal file
147
src/views/workbench/modules/workbench-my-ticket.vue
Normal 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>
|
||||
100
src/views/workbench/modules/workbench-my-week-worklog.vue
Normal file
100
src/views/workbench/modules/workbench-my-week-worklog.vue
Normal 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>
|
||||
114
src/views/workbench/modules/workbench-notice-notification.vue
Normal file
114
src/views/workbench/modules/workbench-notice-notification.vue
Normal 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>
|
||||
73
src/views/workbench/modules/workbench-personal-item.vue
Normal file
73
src/views/workbench/modules/workbench-personal-item.vue
Normal 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>
|
||||
241
src/views/workbench/modules/workbench-product-snapshot.vue
Normal file
241
src/views/workbench/modules/workbench-product-snapshot.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
275
src/views/workbench/modules/workbench-project-snapshot.vue
Normal file
275
src/views/workbench/modules/workbench-project-snapshot.vue
Normal 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>
|
||||
81
src/views/workbench/modules/workbench-recent-visit.vue
Normal file
81
src/views/workbench/modules/workbench-recent-visit.vue
Normal 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>
|
||||
124
src/views/workbench/modules/workbench-risk-alert.vue
Normal file
124
src/views/workbench/modules/workbench-risk-alert.vue
Normal 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>
|
||||
108
src/views/workbench/modules/workbench-team-load.vue
Normal file
108
src/views/workbench/modules/workbench-team-load.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
112
src/views/workbench/modules/workbench-team-worklog.vue
Normal file
112
src/views/workbench/modules/workbench-team-worklog.vue
Normal 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>
|
||||
86
src/views/workbench/modules/workbench-ticket-sla.vue
Normal file
86
src/views/workbench/modules/workbench-ticket-sla.vue
Normal 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>
|
||||
111
src/views/workbench/modules/workbench-worklog-reminder.vue
Normal file
111
src/views/workbench/modules/workbench-worklog-reminder.vue
Normal 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>
|
||||
Reference in New Issue
Block a user