Files
cn-rdms-web/src/views/workbench/modules/workbench-todo-panel.vue

2037 lines
63 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { type Component, computed, markRaw, onActivated, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import dayjs from 'dayjs';
import type { RouteKey } from '@elegant-router/types';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import {
fetchApproveOvertimeApplication,
fetchBatchApproveOvertimeApplication,
fetchBatchRejectOvertimeApplication,
fetchChangePersonalItemStatus,
fetchChangeProjectTaskStatus,
fetchGetMonthlyReportApprovalPage,
fetchGetMyTaskPage,
fetchGetOvertimeApplicationApprovalPage,
fetchGetPersonalItemDetail,
fetchGetPersonalItemPage,
fetchGetProjectReportApprovalPage,
fetchGetProjectTask,
fetchGetWeeklyReportApprovalPage,
fetchPerformanceSheetPage,
fetchRejectOvertimeApplication
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { useDict } from '@/hooks/business/dict';
import type { WorklogChangedPayload } from '@/views/project/project/execution/shared';
import TaskStatusActionDialog from '@/views/project/project/execution/modules/status-action-dialog.vue';
import TaskInfoReadonly from '@/views/project/project/execution/modules/task-info-readonly.vue';
import TaskWorklogDialog from '@/views/project/project/execution/modules/task-worklog-dialog.vue';
import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import PersonalItemStatusActionDialog from '@/views/personal-center/my-item/modules/personal-item-status-action-dialog.vue';
import PerformanceActionDialog from '@/views/personal-center/my-performance/modules/performance-action-dialog.vue';
import PerformanceExcelEditorDrawer from '@/views/personal-center/my-performance/modules/performance-excel-editor-drawer.vue';
import { PerformancePermission } from '@/views/personal-center/my-performance/modules/performance-shared';
import OvertimeApplicationBatchDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-batch-detail-dialog.vue';
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
import {
WORK_REPORT_TYPE_LABEL,
type WorkReportRow,
type WorkReportType,
formatPeriod,
formatWeeklyPeriodLabel
} from '@/views/personal-center/work-report/shared/types';
import {
type WorkbenchTodoDeadlineFilter,
type WorkbenchTodoItem,
type WorkbenchTodoMainTab,
buildWorkbenchTodoItems,
filterWorkbenchTodoItemsByCategory,
filterWorkbenchTodoItemsByDeadline,
isWorkbenchTodoOverdue,
sortWorkbenchTodoItemsByPriority
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import { useWorkbenchWorklogSignal } from '../composables/use-workbench-worklog-signal';
import WorkbenchModuleCard from './workbench-module-card.vue';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardCheckOutline from '~icons/mdi/clipboard-check-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiPauseCircleOutline from '~icons/mdi/pause-circle-outline';
import IconMdiPlayCircleOutline from '~icons/mdi/play-circle-outline';
type SortKey = 'created' | 'priority' | 'deadline';
type OvertimeApprovalActionType = 'approve' | 'reject';
type PerformanceApprovalActionType = 'confirm' | 'reject';
type ApprovalBizType = 'overtime_application' | 'performance' | WorkReportType;
defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
editing?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{
(e: 'hide'): void;
}>();
const router = useRouter();
const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonPermissions = computed(() => new Set(authStore.userInfo.buttons || []));
function hasButtonPermission(permission: string) {
return buttonPermissions.value.has(permission);
}
// 工时填报在工作台内弹层完成不切路由需广播给「我的工时」widget 重拉
const { notify: notifyWorklogChanged } = useWorkbenchWorklogSignal();
const { loading, refresh } = useWorkbenchRefresh(async () => {
await Promise.all([
loadMyTaskItems(),
loadPersonalTodoItems(),
loadOvertimeApprovalItems(),
loadPerformanceApprovalItems(),
loadWorkReportApprovalItems()
]);
});
const PAGE_SIZE = 5;
const activeTab = ref<WorkbenchTodoMainTab>('all');
const activeDeadlineFilter = ref<WorkbenchTodoDeadlineFilter>(null);
const activeApprovalBizType = ref<ApprovalBizType>('weekly');
const activeSort = ref<SortKey>('deadline');
const currentPage = ref(1);
const sortOptions = computed<Array<{ key: SortKey; label: string }>>(() => {
const base: Array<{ key: SortKey; label: string }> = [
{ key: 'deadline', label: '截止时间' },
{ key: 'created', label: '创建时间' }
];
if (activeTab.value === 'task') {
base.push({ key: 'priority', label: '优先级' });
}
return base;
});
const mainTabs: Array<{ key: WorkbenchTodoMainTab; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'task', label: '任务' },
{ key: 'ticket', label: '工单' },
{ key: 'personal', label: '我的事项' },
{ key: 'approval', label: '待审批' },
{ key: 'confirm', label: '待确认' }
];
const deadlineFilters: Array<{ key: Exclude<WorkbenchTodoDeadlineFilter, null>; label: string }> = [
{ key: 'overdue', label: '已逾期' },
{ key: 'today', label: '今日到期' },
{ key: 'week', label: '本周到期' }
];
const approvalBizTabs: Array<{ key: ApprovalBizType; label: string }> = [
{ key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' },
{ key: 'project', label: '项目半月报' },
{ key: 'overtime_application', label: '业务学习' }
];
const confirmBizTabs: Array<{ key: Extract<ApprovalBizType, 'performance'>; label: string }> = [
{ key: 'performance', label: '绩效表' }
];
const hasWorkReportApprovePermission = computed(() => hasButtonPermission('project:work-report:approve'));
const hasOvertimeApprovePermission = computed(() => hasButtonPermission('project:overtime-application:approve'));
const hasPerformanceApprovePermission = computed(
() =>
hasButtonPermission(PerformancePermission.SheetConfirm) && hasButtonPermission(PerformancePermission.SheetReject)
);
const visibleMainTabs = computed(() =>
mainTabs.filter(tab => {
if (tab.key === 'confirm') {
return hasPerformanceApprovePermission.value;
}
return true;
})
);
const visibleApprovalBizTabs = computed(() =>
approvalBizTabs.filter(tab => {
if (tab.key === 'performance') {
return hasPerformanceApprovePermission.value;
}
if (tab.key === 'overtime_application') {
return hasOvertimeApprovePermission.value;
}
return hasWorkReportApprovePermission.value;
})
);
const visibleConfirmBizTabs = computed(() => (hasPerformanceApprovePermission.value ? confirmBizTabs : []));
const myTaskItems = ref<WorkbenchTodoItem[]>([]);
// 保留任务原始行,供操作图标按 availableActions 渲染并取 projectId / executionId 调状态变更接口
const myTaskRows = ref<Api.Project.MyTaskItem[]>([]);
const personalTodoItems = ref<WorkbenchTodoItem[]>([]);
// 保留我的事项原始行,供操作图标(详情/填报/完成)按 availableActions 渲染并回传给弹层
const personalItemRows = ref<Api.PersonalItem.PersonalItem[]>([]);
const overtimeApprovalItems = ref<WorkbenchTodoItem[]>([]);
const overtimeApprovalRows = ref<Api.OvertimeApplication.OvertimeApplication[]>([]);
const performanceApprovalItems = ref<WorkbenchTodoItem[]>([]);
const performanceApprovalRows = ref<Api.Performance.Sheet.Sheet[]>([]);
const workReportApprovalItems = ref<WorkbenchTodoItem[]>([]);
const weeklyApprovalRows = ref<Api.WorkReport.Weekly.WeeklyReport[]>([]);
const monthlyApprovalRows = ref<Api.WorkReport.Monthly.MonthlyReport[]>([]);
const projectApprovalRows = ref<Api.WorkReport.Project.ProjectReport[]>([]);
// 工单 tab 等工单业务上线,当前为空态
const mergedItems = computed(() => [
...myTaskItems.value,
...personalTodoItems.value,
...overtimeApprovalItems.value,
...performanceApprovalItems.value,
...workReportApprovalItems.value
]);
// 我的事项操作弹层:详情用 view 模式、新增用 add 模式,共用同一实例
const personalOperateVisible = ref(false);
const personalOperateType = ref<'add' | 'view'>('add');
const personalOperateRow = ref<Api.PersonalItem.PersonalItem | null>(null);
// 我的事项填报:复用详情弹层的 worklog tab
const personalDetailVisible = ref(false);
const personalDetailRow = ref<Api.PersonalItem.PersonalItem | null>(null);
// 我的事项完成:复用状态动作弹层收集原因 + 二次确认
const personalStatusVisible = ref(false);
const personalStatusRow = ref<Api.PersonalItem.PersonalItem | null>(null);
const personalStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
// 任务生命周期动作:复用任务工作区的状态动作弹层收集原因
const taskStatusVisible = ref(false);
const taskStatusRow = ref<Api.Project.MyTaskItem | null>(null);
const taskStatusAction = ref<Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null>(null);
const taskPreviewMap = ref<Record<string, Api.Project.ProjectTask>>({});
const taskPreviewLoadingIds = ref<string[]>([]);
// 任务填报工时:复用任务工作区的工时弹层;弹层需要完整任务详情,点击时按需拉取
const taskWorklogVisible = ref(false);
const taskWorklogTask = ref<Api.Project.ProjectTask | null>(null);
const overtimeDetailVisible = ref(false);
const overtimeActionSubmitting = ref(false);
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const batchDetailVisible = ref(false);
const batchSubmitting = ref(false);
const workReportDetailVisible = ref(false);
const currentWorkReport = ref<WorkReportRow | null>(null);
const currentWorkReportType = ref<WorkReportType>('weekly');
const performanceDetailVisible = ref(false);
const performanceActionVisible = ref(false);
const currentPerformanceSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
const currentPerformanceActionType = ref<PerformanceApprovalActionType>('confirm');
// 批量审批选中状态(存原始业务学习 id避免映射转换
const selectedOvertimeIds = ref<Set<string>>(new Set());
const APPROVAL_ENTRY_ICON = markRaw(IconMdiClipboardCheckOutline);
function getApprovalCategoryLabel(bizType: ApprovalBizType) {
if (bizType === 'weekly') return '周报';
if (bizType === 'monthly') return '月报';
if (bizType === 'project') return '项目半月报';
if (bizType === 'performance') return '绩效表';
if (bizType === 'overtime_application') return '业务学习';
return '待审批';
}
const PERSONAL_ACTION_ICONS = {
detail: markRaw(IconMdiEyeOutline),
worklog: markRaw(IconMdiClipboardEditOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
// auto_start 是系统自动动作,接口不返回;无图标的未知动作不渲染按钮
const TASK_ACTION_ICONS: Partial<Record<Api.Project.ProjectTaskActionCode, Component>> = {
pause: markRaw(IconMdiPauseCircleOutline),
resume: markRaw(IconMdiPlayCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function normalizeTodoDeadline(deadline: string | null) {
if (!deadline) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(deadline)) {
return dayjs(deadline).endOf('day').format('YYYY-MM-DD HH:mm');
}
return deadline;
}
function getTaskActionButtonType(code: Api.Project.ProjectTaskActionCode) {
if (code === 'complete') return 'success' as const;
if (code === 'cancel') return 'danger' as const;
return 'primary' as const;
}
function getTodoProgress(item: WorkbenchTodoItem) {
if (typeof item.progressRate !== 'number' || !Number.isFinite(item.progressRate)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(item.progressRate)));
}
function shouldShowTodoProgress(item: WorkbenchTodoItem) {
return (item.category === 'task' || item.category === 'personal') && typeof item.progressRate === 'number';
}
function handleOpenAdd() {
personalOperateType.value = 'add';
personalOperateRow.value = null;
personalOperateVisible.value = true;
}
async function handleAddSubmitted() {
activeTab.value = 'personal';
activeDeadlineFilter.value = null;
await loadPersonalTodoItems();
}
function findPersonalItemRow(item: WorkbenchTodoItem) {
return personalItemRows.value.find(row => `personal-${row.id}` === item.id) || null;
}
function getPersonalCompleteAction(row: Api.PersonalItem.PersonalItem) {
return row.availableActions?.find(action => action.actionCode === 'complete') || null;
}
// 仅当我的事项当前可完成availableActions 含 complete时才渲染完成图标
function canCompletePersonalItem(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
return Boolean(row && getPersonalCompleteAction(row));
}
// 详情:复用我的事项操作弹层的 view 只读模式
function openPersonalDetail(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
personalOperateType.value = 'view';
personalOperateRow.value = row;
personalOperateVisible.value = true;
}
// 填报:拉最新详情后打开详情弹层的 worklog tab
async function openPersonalWorklog(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
const { error, data } = await fetchGetPersonalItemDetail(row.id);
personalDetailRow.value = error || !data ? row : data;
personalDetailVisible.value = true;
}
// 完成:走状态动作弹层(二次确认 + 按 needReason 收集原因)
function openPersonalComplete(item: WorkbenchTodoItem) {
const row = findPersonalItemRow(item);
if (!row) return;
const completeAction = getPersonalCompleteAction(row);
if (!completeAction) return;
personalStatusRow.value = row;
personalStatusAction.value = completeAction;
personalStatusVisible.value = true;
}
async function handlePersonalStatusSubmit(reason: string | null) {
if (!personalStatusRow.value || !personalStatusAction.value) return;
const { error } = await fetchChangePersonalItemStatus(personalStatusRow.value.id, {
actionCode: personalStatusAction.value.actionCode,
reason
});
if (error) return;
personalStatusVisible.value = false;
window.$message?.success(`${personalStatusAction.value.actionName}成功`);
await loadPersonalTodoItems();
}
async function handlePersonalWorklogChanged() {
notifyWorklogChanged();
await loadPersonalTodoItems();
}
function findMyTaskRow(item: WorkbenchTodoItem) {
return myTaskRows.value.find(row => `task-${row.id}` === item.id) || null;
}
function canPreviewTaskInfo(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
return Boolean(item.category === 'task' && row?.projectId && row.executionId);
}
function getTaskPreviewTask(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row) return null;
return taskPreviewMap.value[row.id] || null;
}
function getTaskPreviewTaskOptions(item: WorkbenchTodoItem) {
const task = getTaskPreviewTask(item);
return task ? [task] : [];
}
function isTaskPreviewLoading(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row) return false;
return taskPreviewLoadingIds.value.includes(row.id);
}
async function handleTaskPreviewShow(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row?.executionId) return;
if (taskPreviewMap.value[row.id] || taskPreviewLoadingIds.value.includes(row.id)) {
return;
}
taskPreviewLoadingIds.value = [...taskPreviewLoadingIds.value, row.id];
const { error, data } = await fetchGetProjectTask(row.projectId, row.executionId, row.id);
taskPreviewLoadingIds.value = taskPreviewLoadingIds.value.filter(id => id !== row.id);
if (error || !data) {
return;
}
taskPreviewMap.value = {
...taskPreviewMap.value,
[row.id]: data
};
}
// 状态变更接口挂在执行路径下,未挂执行的任务不渲染动作按钮
function getTaskActions(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row?.executionId) return [];
return row.availableActions.filter(action => Boolean(TASK_ACTION_ICONS[action.actionCode]));
}
// 填报工时同样依赖执行路径(工时接口挂在 project/execution/task 下)
function canReportTaskWorklog(item: WorkbenchTodoItem) {
return Boolean(findMyTaskRow(item)?.executionId);
}
async function refreshTaskWorklogTaskDetail(
target?:
| Pick<Api.Project.ProjectTask, 'id' | 'projectId' | 'executionId'>
| Pick<Api.Project.MyTaskItem, 'id' | 'projectId' | 'executionId'>
| null
) {
const task = target ?? taskWorklogTask.value;
if (!task?.executionId) return;
const { error, data } = await fetchGetProjectTask(task.projectId, task.executionId, task.id);
if (error || !data) return;
if (taskWorklogTask.value?.id === data.id) {
taskWorklogTask.value = data;
}
}
// 填报:工时弹层需要完整任务详情(负责人/状态/日期),按需拉一次详情再打开
async function openTaskWorklog(item: WorkbenchTodoItem) {
const row = findMyTaskRow(item);
if (!row?.executionId) return;
const { error, data } = await fetchGetProjectTask(row.projectId, row.executionId, row.id);
if (error || !data) return;
taskWorklogTask.value = data;
taskWorklogVisible.value = true;
}
// 填报会联动任务进度/状态(如 auto_start变更后刷新任务列表
async function handleTaskWorklogChanged(payload: WorklogChangedPayload) {
notifyWorklogChanged();
await loadMyTaskItems();
if (taskWorklogTask.value?.id === payload.taskId) {
await refreshTaskWorklogTaskDetail();
}
// 与任务工作区联动一致:进度填到 100 且我是任务负责人时提示完成(仅单任务,不做级联)
if (payload.mode === 'delete' || payload.progressRate !== 100) return;
const task = taskWorklogTask.value;
if (!task || task.id !== payload.taskId || task.ownerId !== currentUserId.value) return;
// 以刷新后的行为准:后端按进度/负责人口径返回了 complete 才提示
const row = myTaskRows.value.find(item => item.id === payload.taskId);
const completeAction = row?.availableActions.find(action => action.actionCode === 'complete');
if (!row || !completeAction) return;
taskStatusRow.value = row;
taskStatusAction.value = completeAction;
try {
await window.$messageBox?.confirm('任务进度已达 100%,是否完成当前任务?', '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '仅保留工时',
type: 'info'
});
} catch {
return;
}
await submitTaskStatusChange(null);
}
const taskStatusActionTitle = computed(() =>
taskStatusAction.value ? `任务状态变更:${taskStatusAction.value.actionName}` : '任务状态变更'
);
async function handleTaskAction(
item: WorkbenchTodoItem,
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>
) {
const row = findMyTaskRow(item);
if (!row?.executionId) return;
taskStatusRow.value = row;
taskStatusAction.value = action;
// 完成动作:二次确认后直接提交(与任务工作区同口径;有未完成子任务时后端会拒绝,按业务错误提示)
if (action.actionCode === 'complete') {
try {
await window.$messageBox?.confirm(`确定要完成任务“${row.taskTitle}”吗?`, '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await submitTaskStatusChange(null);
return;
}
// 其他非必填原因的动作(暂停/恢复)直接提交,不弹原因弹层
if (!action.needReason) {
await submitTaskStatusChange(null);
return;
}
taskStatusVisible.value = true;
}
async function submitTaskStatusChange(reason: string | null) {
const row = taskStatusRow.value;
const action = taskStatusAction.value;
if (!row?.executionId || !action) return;
const { error } = await fetchChangeProjectTaskStatus(row.projectId, row.executionId, {
taskId: row.id,
data: { actionCode: action.actionCode, reason }
});
if (error) return;
taskStatusVisible.value = false;
window.$message?.success(`${action.actionName}成功`);
await loadMyTaskItems();
if (taskWorklogTask.value?.id === row.id) {
await refreshTaskWorklogTaskDetail(row);
}
}
const tabCounts = computed(() => {
const counts: Record<WorkbenchTodoMainTab, number> = {
all: mergedItems.value.length,
task: 0,
ticket: 0,
personal: 0,
approval: 0,
confirm: 0
};
mergedItems.value.forEach(item => {
counts[item.category] += 1;
});
return counts;
});
const tabOverdueCount = computed(() => {
const map: Record<WorkbenchTodoMainTab, number> = {
all: 0,
task: 0,
ticket: 0,
personal: 0,
approval: 0,
confirm: 0
};
mergedItems.value.forEach(item => {
if (!isWorkbenchTodoOverdue(item)) return;
map.all += 1;
map[item.category] += 1;
});
return map;
});
const itemsInTab = computed(() => filterWorkbenchTodoItemsByCategory(mergedItems.value, activeTab.value));
const filteredItems = computed(() => {
if (activeTab.value === 'approval') {
return itemsInTab.value.filter(item => item.approvalBizType === activeApprovalBizType.value);
}
if (activeTab.value === 'confirm') {
return itemsInTab.value.filter(item => item.approvalBizType === 'performance');
}
return filterWorkbenchTodoItemsByDeadline(itemsInTab.value, activeDeadlineFilter.value);
});
const approvalBizTabCounts = computed(() => {
const counts: Record<ApprovalBizType, number> = {
overtime_application: 0,
performance: 0,
weekly: 0,
monthly: 0,
project: 0
};
itemsInTab.value.forEach(item => {
if (item.approvalBizType === 'overtime_application') {
counts.overtime_application += 1;
return;
}
if (item.approvalBizType === 'performance') {
counts.performance += 1;
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
counts[item.approvalBizType] += 1;
}
});
return counts;
});
const sortedItems = computed(() => {
const base = filteredItems.value;
if (activeSort.value === 'priority') {
return sortWorkbenchTodoItemsByPriority(base, 'desc');
}
if (activeSort.value === 'deadline') {
return [...base].sort((left, right) => {
const leftValue = left.remainingDays === null ? Number.POSITIVE_INFINITY : left.remainingDays;
const rightValue = right.remainingDays === null ? Number.POSITIVE_INFINITY : right.remainingDays;
return leftValue - rightValue;
});
}
return base;
});
const currentSortLabel = computed(
() => sortOptions.value.find(option => option.key === activeSort.value)?.label ?? '排序'
);
const pagedItems = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return sortedItems.value.slice(start, start + PAGE_SIZE);
});
watch([activeTab, activeDeadlineFilter, activeSort], () => {
currentPage.value = 1;
});
watch(
visibleApprovalBizTabs,
tabs => {
if (!tabs.length) return;
if (!tabs.some(tab => tab.key === activeApprovalBizType.value)) {
activeApprovalBizType.value = tabs[0].key;
}
},
{ immediate: true }
);
watch(
visibleMainTabs,
tabs => {
if (!tabs.some(tab => tab.key === activeTab.value)) {
activeTab.value = tabs[0]?.key ?? 'all';
}
},
{ immediate: true }
);
function handleSelectTab(key: WorkbenchTodoMainTab) {
if (key === 'confirm' && !hasPerformanceApprovePermission.value) return;
if (activeTab.value === key) return;
activeTab.value = key;
activeDeadlineFilter.value = null;
if (key === 'confirm') {
activeApprovalBizType.value = 'performance';
} else if (key === 'approval' && activeApprovalBizType.value === 'performance') {
activeApprovalBizType.value = visibleApprovalBizTabs.value[0]?.key ?? 'weekly';
}
if (key !== 'task' && activeSort.value === 'priority') {
activeSort.value = 'deadline';
}
}
function handleSelectDeadlineFilter(key: Exclude<WorkbenchTodoDeadlineFilter, null>) {
activeDeadlineFilter.value = activeDeadlineFilter.value === key ? null : key;
}
function handleSelectApprovalBizType(key: ApprovalBizType) {
activeApprovalBizType.value = key;
}
function handleSelectSort(key: SortKey) {
activeSort.value = key;
}
function handleClickItem(item: WorkbenchTodoItem) {
if (item.approvalBizType === 'overtime_application') {
openOvertimeDetail(item);
return;
}
if (item.approvalBizType === 'performance') {
openPerformanceDetail(item);
return;
}
if (isWorkReportApprovalBizType(item.approvalBizType)) {
openWorkReportDetail(item);
return;
}
// 任务条目:带项目对象上下文跳进该项目的任务导航(执行池页)
if (item.category === 'task' && item.projectId) {
router.push({
path: '/project/project/execution',
query: { [OBJECT_CONTEXT_QUERY_KEY]: item.projectId }
});
return;
}
if (!item.routeKey) return;
routerPushByKey(item.routeKey as RouteKey);
}
function isWorkReportApprovalBizType(value?: string): value is WorkReportType {
return value === 'weekly' || value === 'monthly' || value === 'project';
}
function findOvertimeApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId) {
return null;
}
return overtimeApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function findPerformanceApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId) {
return null;
}
return performanceApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function openOvertimeDetail(item: WorkbenchTodoItem) {
const row = findOvertimeApprovalRow(item);
if (!row) return;
currentOvertimeApplication.value = row;
overtimeDetailVisible.value = true;
}
function openPerformanceDetail(item: WorkbenchTodoItem) {
const row = findPerformanceApprovalRow(item);
if (!row) return;
currentPerformanceSheet.value = row;
performanceDetailVisible.value = true;
}
function openCurrentPerformanceAction(actionType: PerformanceApprovalActionType) {
if (!currentPerformanceSheet.value) return;
currentPerformanceActionType.value = actionType;
performanceActionVisible.value = true;
}
function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) {
return null;
}
if (item.approvalBizType === 'weekly') {
return weeklyApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
if (item.approvalBizType === 'monthly') {
return monthlyApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
return projectApprovalRows.value.find(row => row.id === item.approvalBizId) || null;
}
function openWorkReportDetail(item: WorkbenchTodoItem) {
const row = findWorkReportApprovalRow(item);
if (!row || !isWorkReportApprovalBizType(item.approvalBizType)) return;
currentWorkReport.value = row;
currentWorkReportType.value = item.approvalBizType;
workReportDetailVisible.value = true;
}
async function handleOvertimeActionSubmit(payload: { actionType: OvertimeApprovalActionType; reason: string | null }) {
if (!currentOvertimeApplication.value) {
return;
}
overtimeActionSubmitting.value = true;
const result =
payload.actionType === 'approve'
? await fetchApproveOvertimeApplication(currentOvertimeApplication.value.id, { reason: payload.reason })
: await fetchRejectOvertimeApplication(currentOvertimeApplication.value.id, { reason: payload.reason });
overtimeActionSubmitting.value = false;
if (result.error) {
return;
}
overtimeDetailVisible.value = false;
window.$message?.success(payload.actionType === 'approve' ? '业务学习已通过' : '业务学习已退回');
await loadOvertimeApprovalItems();
}
async function handleWorkReportSubmitted() {
workReportDetailVisible.value = false;
await loadWorkReportApprovalItems();
}
async function handlePerformanceActionSubmitted() {
performanceActionVisible.value = false;
performanceDetailVisible.value = false;
await loadPerformanceApprovalItems();
}
// 优先级角标用字典 label 原样回显rdms_req_priorityP0~P3不翻译成高/中/低
const { getLabel: getPriorityLabel } = useDict(RDMS_REQ_PRIORITY_DICT_CODE);
// 任务优先级字典 value"0" P0 ~ "3" P3数字越小越高映射待办优先级仅用于排序与高亮权重
function mapTaskTodoPriority(priority: string) {
if (priority === '0') return 'high' as const;
if (priority === '1') return 'mid' as const;
return 'low' as const;
}
// 我的任务:跨项目单接口聚合(负责/协办并集、只返回非终态,过滤排序、进度与可执行动作均由后端完成)
async function loadMyTaskItems() {
const { error, data } = await fetchGetMyTaskPage({ pageNo: 1, pageSize: -1 });
taskPreviewMap.value = {};
taskPreviewLoadingIds.value = [];
if (error || !data) {
myTaskRows.value = [];
myTaskItems.value = [];
return;
}
myTaskRows.value = data.list;
myTaskItems.value = buildWorkbenchTodoItems(
data.list.map(task => ({
id: `task-${task.id}`,
category: 'task' as const,
title: task.taskTitle,
createdTime: task.createTime,
deadline: normalizeTodoDeadline(task.plannedEndDate),
source: task.projectName,
progressRate: task.progressRate,
priority: mapTaskTodoPriority(task.priority),
priorityLabel: getPriorityLabel(task.priority) || task.priority,
projectId: task.projectId
}))
);
}
// 待办口径未到终态的我的事项pending / active / pausedterminal 态completed / cancelled不进待办
const PERSONAL_TODO_STATUSES: Api.PersonalItem.PersonalItemStatusCode[] = ['pending', 'active', 'paused'];
async function loadPersonalTodoItems() {
const { error, data } = await fetchGetPersonalItemPage({ pageNo: 1, pageSize: 100 });
if (error || !data) {
personalItemRows.value = [];
personalTodoItems.value = [];
return;
}
const rows = data.list.filter(item => PERSONAL_TODO_STATUSES.includes(item.statusCode));
personalItemRows.value = rows;
personalTodoItems.value = buildWorkbenchTodoItems(
rows.map(item => ({
id: `personal-${item.id}`,
category: 'personal',
title: item.taskTitle,
createdTime: item.createTime,
deadline: normalizeTodoDeadline(item.plannedEndDate),
source: '我的事项',
progressRate: item.progressRate,
routeKey: 'personal-center_my-item'
}))
);
}
// 批量审批选中状态管理
function isOvertimeItemSelected(item: WorkbenchTodoItem) {
return item.approvalBizId ? selectedOvertimeIds.value.has(item.approvalBizId) : false;
}
function toggleOvertimeItem(item: WorkbenchTodoItem, checked: boolean) {
if (!item.approvalBizId) return;
if (checked) {
selectedOvertimeIds.value.add(item.approvalBizId);
} else {
selectedOvertimeIds.value.delete(item.approvalBizId);
}
}
function toggleSelectAllOvertimeItems(checked: boolean) {
if (checked) {
overtimeApprovalItems.value.forEach(item => {
if (item.approvalBizId) selectedOvertimeIds.value.add(item.approvalBizId);
});
} else {
selectedOvertimeIds.value.clear();
}
}
function clearOvertimeSelection() {
selectedOvertimeIds.value.clear();
}
// 切换审批子页签时清空选中
watch(activeApprovalBizType, () => {
clearOvertimeSelection();
});
// 当前页业务学习是否全部选中(用于全选复选框状态)
const allOvertimeItemsSelected = computed(() => {
const items = overtimeApprovalItems.value;
if (items.length === 0) return false;
return items.every(item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId));
});
// 当前页是否有部分选中
const someOvertimeItemsSelected = computed(() => {
return overtimeApprovalItems.value.some(
item => item.approvalBizId && selectedOvertimeIds.value.has(item.approvalBizId)
);
});
// 批量审批选中的 id 数组(用于传给批量详情弹窗)
const batchSelectedIds = computed(() => Array.from(selectedOvertimeIds.value));
// 打开批量审批详情弹窗
function handleBatchReview() {
batchDetailVisible.value = true;
}
// 批量审批提交(对所有选中项执行批量 API
async function handleBatchActionSubmit(payload: { actionType: OvertimeApprovalActionType; reason: string | null }) {
const ids = Array.from(selectedOvertimeIds.value);
if (ids.length === 0) return;
const fn =
payload.actionType === 'approve' ? fetchBatchApproveOvertimeApplication : fetchBatchRejectOvertimeApplication;
batchSubmitting.value = true;
const { error, data } = await fn({ ids, reason: payload.reason });
batchSubmitting.value = false;
if (error || !data) return;
batchDetailVisible.value = false;
clearOvertimeSelection();
if (data.failCount > 0) {
window.$message?.warning(`成功 ${data.successCount} 条,失败 ${data.failCount}`);
} else {
window.$message?.success(`已批量处理 ${data.successCount}`);
}
await loadOvertimeApprovalItems();
}
async function loadOvertimeApprovalItems() {
if (!hasOvertimeApprovePermission.value) {
overtimeApprovalRows.value = [];
overtimeApprovalItems.value = [];
return;
}
const { error, data } = await fetchGetOvertimeApplicationApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending',
keyword: undefined,
applicantName: undefined,
approverId: undefined,
approverName: undefined,
overtimeDate: undefined,
createTime: undefined
});
if (error || !data) {
overtimeApprovalRows.value = [];
overtimeApprovalItems.value = [];
return;
}
overtimeApprovalRows.value = data.list;
overtimeApprovalItems.value = buildWorkbenchTodoItems(
data.list.map(item => ({
id: `overtime-application-${item.id}`,
category: 'approval',
categoryLabel: '业务学习',
title: `${item.applicantName} · ${item.overtimeDate.slice(5, 7)} 月学习 ${item.overtimeDuration} 申请待审批`,
createdTime: item.submitTime || item.createTime,
deadline: item.submitTime || item.createTime,
source: `业务学习 · ${item.applicantName}`,
priority: 'mid',
approvalBizType: 'overtime_application',
approvalBizId: item.id
}))
);
}
async function loadPerformanceApprovalItems() {
if (!hasPerformanceApprovePermission.value) {
performanceApprovalRows.value = [];
performanceApprovalItems.value = [];
return;
}
const { error, data } = await fetchPerformanceSheetPage({
pageNo: 1,
pageSize: 20,
statusCode: 'sent'
});
if (error || !data) {
performanceApprovalRows.value = [];
performanceApprovalItems.value = [];
return;
}
performanceApprovalRows.value = data.list;
performanceApprovalItems.value = buildWorkbenchTodoItems(
data.list.map(item => ({
id: `performance-${item.id}`,
category: 'confirm',
categoryLabel: '绩效表',
title: `${item.periodMonth} 绩效表待确认`,
createdTime: item.sentTime || item.createTime || item.updateTime || '',
deadline: item.sentTime || item.createTime || item.updateTime || null,
source: `绩效表 · ${item.managerName || '--'}`,
priority: 'mid',
approvalBizType: 'performance',
approvalBizId: item.id
}))
);
}
function buildWorkReportApprovalItems<T extends WorkReportRow>(
bizType: WorkReportType,
rows: T[]
): WorkbenchTodoItem[] {
const reportTypeLabel = WORK_REPORT_TYPE_LABEL[bizType];
return buildWorkbenchTodoItems(
rows.map(item => ({
id: `${bizType}-${item.id}`,
category: 'approval',
categoryLabel: getApprovalCategoryLabel(bizType),
title: `${reportTypeLabel} · ${bizType === 'weekly' ? formatWeeklyPeriodLabel(item) : formatPeriod(item)} 待审批`,
createdTime: item.submitTime || item.createTime || '',
deadline: item.submitTime || item.createTime || null,
source: `${reportTypeLabel} · ${'projectName' in item ? item.projectName : item.reporterName}`,
priority: 'mid',
approvalBizType: bizType,
approvalBizId: item.id
}))
);
}
async function loadWorkReportApprovalItems() {
if (!hasWorkReportApprovePermission.value) {
weeklyApprovalRows.value = [];
monthlyApprovalRows.value = [];
projectApprovalRows.value = [];
workReportApprovalItems.value = [];
return;
}
const [weeklyResult, monthlyResult, projectResult] = await Promise.all([
fetchGetWeeklyReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined,
isBusinessTrip: undefined
}),
fetchGetMonthlyReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined
}),
fetchGetProjectReportApprovalPage({
pageNo: 1,
pageSize: 20,
statusCode: 'pending_approval',
keyword: undefined,
periodStartDate: undefined,
submitTime: undefined,
supervisorName: undefined,
projectId: undefined,
flag: undefined
})
]);
weeklyApprovalRows.value = weeklyResult.error || !weeklyResult.data ? [] : weeklyResult.data.list;
monthlyApprovalRows.value = monthlyResult.error || !monthlyResult.data ? [] : monthlyResult.data.list;
projectApprovalRows.value = projectResult.error || !projectResult.data ? [] : projectResult.data.list;
workReportApprovalItems.value = [
...buildWorkReportApprovalItems('weekly', weeklyApprovalRows.value),
...buildWorkReportApprovalItems('monthly', monthlyApprovalRows.value),
...buildWorkReportApprovalItems('project', projectApprovalRows.value)
];
}
function getDeadlineToneClass(item: WorkbenchTodoItem) {
if (isWorkbenchTodoOverdue(item)) return 'workbench-todo__deadline--rose';
if (item.remainingDays === 0) return 'workbench-todo__deadline--amber';
return 'workbench-todo__deadline--slate';
}
// 工作台路由 keepAlive切回不重挂用 onActivated 替代 onMounted每次激活经 refresh 重拉(首挂也会触发,不漏首屏)
onActivated(refresh);
</script>
<template>
<WorkbenchModuleCard
v-loading="loading"
title="我的待办"
icon="mdi:clipboard-text-clock-outline"
:editing="editing"
@hide="$emit('hide')"
@refresh="refresh"
>
<div class="workbench-todo__tabs">
<div class="workbench-todo__tabs-group">
<ElTooltip
v-for="tab in visibleMainTabs"
:key="tab.key"
:content="`已逾期 ${tabOverdueCount[tab.key]} 项,建议尽快处理`"
:disabled="tabOverdueCount[tab.key] === 0"
placement="top"
effect="dark"
>
<button
type="button"
class="workbench-todo__tab"
:class="{ 'workbench-todo__tab--active': activeTab === tab.key }"
@click="handleSelectTab(tab.key)"
>
<span>{{ tab.label }}</span>
<span class="workbench-todo__tab-count">{{ tabCounts[tab.key] }}</span>
<span v-if="tabOverdueCount[tab.key] > 0" class="workbench-todo__tab-dot" />
</button>
</ElTooltip>
</div>
<button type="button" class="workbench-todo__add" @click="handleOpenAdd">
<SvgIcon icon="mdi:plus" class="workbench-todo__add-icon" />
<span>我的事项</span>
</button>
</div>
<div class="workbench-todo__filters">
<div v-if="activeTab !== 'approval' && activeTab !== 'confirm'" class="workbench-todo__filters-left">
<button
v-for="filter in deadlineFilters"
:key="filter.key"
type="button"
class="workbench-todo__filter"
:class="{ 'workbench-todo__filter--active': activeDeadlineFilter === filter.key }"
@click="handleSelectDeadlineFilter(filter.key)"
>
{{ filter.label }}
</button>
</div>
<div v-else-if="activeTab === 'approval'" class="workbench-todo__filters-left">
<button
v-for="tab in visibleApprovalBizTabs"
:key="tab.key"
type="button"
class="workbench-todo__filter"
:class="{ 'workbench-todo__filter--active': activeApprovalBizType === tab.key }"
@click="handleSelectApprovalBizType(tab.key)"
>
{{ tab.label }}
<span class="workbench-todo__filter-count">{{ approvalBizTabCounts[tab.key] }}</span>
</button>
</div>
<div v-else class="workbench-todo__filters-left">
<button
v-for="tab in visibleConfirmBizTabs"
:key="tab.key"
type="button"
class="workbench-todo__filter"
:class="{ 'workbench-todo__filter--active': activeApprovalBizType === tab.key }"
@click="handleSelectApprovalBizType(tab.key)"
>
{{ tab.label }}
<span class="workbench-todo__filter-count">{{ approvalBizTabCounts[tab.key] }}</span>
</button>
</div>
<ElDropdown trigger="click" placement="bottom-end" @command="handleSelectSort">
<span class="workbench-todo__sort">
排序{{ currentSortLabel }}
<SvgIcon icon="mdi:chevron-down" class="workbench-todo__sort-icon" />
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="option in sortOptions"
:key="option.key"
:command="option.key"
:class="{ 'is-active': activeSort === option.key }"
>
{{ option.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="workbench-todo__content">
<!-- 批量操作栏仅业务学习待审批时显示 -->
<div
v-if="
activeTab === 'approval' &&
activeApprovalBizType === 'overtime_application' &&
overtimeApprovalItems.length > 0
"
class="workbench-todo__batch-bar"
:class="{ 'workbench-todo__batch-bar--active': selectedOvertimeIds.size > 0 }"
>
<div class="workbench-todo__batch-bar-left">
<ElCheckbox
:model-value="allOvertimeItemsSelected"
:indeterminate="someOvertimeItemsSelected && !allOvertimeItemsSelected"
@change="val => toggleSelectAllOvertimeItems(Boolean(val))"
@click.stop
>
全选
</ElCheckbox>
<span v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-count">
已选择 {{ selectedOvertimeIds.size }} 项
</span>
</div>
<div v-if="selectedOvertimeIds.size > 0" class="workbench-todo__batch-bar-right">
<ElButton size="small" type="primary" :loading="batchSubmitting" @click.stop="handleBatchReview">
批量审批
</ElButton>
<ElButton size="small" link @click.stop="clearOvertimeSelection">取消选择</ElButton>
</div>
</div>
<div v-if="pagedItems.length" class="workbench-todo__list">
<article
v-for="item in pagedItems"
:key="item.id"
class="workbench-todo__item"
:class="{
'workbench-todo__item--selected': isOvertimeItemSelected(item)
}"
>
<div class="workbench-todo__leading">
<!-- 业务学习待审批时显示复选框 -->
<ElCheckbox
v-if="
activeTab === 'approval' &&
activeApprovalBizType === 'overtime_application' &&
item.approvalBizType === 'overtime_application'
"
:model-value="isOvertimeItemSelected(item)"
@change="val => toggleOvertimeItem(item, Boolean(val))"
@click.stop
/>
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }}
</span>
<span
v-if="item.priorityLabel"
class="workbench-todo__priority"
:class="{ 'workbench-todo__priority--high': item.priority === 'high' }"
>
{{ item.priorityLabel }}
</span>
</div>
<div class="workbench-todo__body">
<h4 class="workbench-todo__item-title">
<ElPopover
v-if="canPreviewTaskInfo(item)"
placement="right-start"
trigger="hover"
:width="960"
popper-class="workbench-task-preview-popover"
@show="handleTaskPreviewShow(item)"
>
<template #reference>
<span
class="workbench-todo__item-title-text"
:class="{
'workbench-todo__item-title-text--clickable': Boolean(
item.routeKey || item.approvalBizType || item.projectId
)
}"
@click="handleClickItem(item)"
>
{{ item.title }}
</span>
</template>
<div class="workbench-task-preview">
<div class="workbench-task-preview__header">任务信息</div>
<div v-loading="isTaskPreviewLoading(item)" class="workbench-task-preview__body">
<TaskInfoReadonly
v-if="getTaskPreviewTask(item)"
:task="getTaskPreviewTask(item)"
:task-options="getTaskPreviewTaskOptions(item)"
/>
<ElEmpty v-else description="暂无任务信息" :image-size="56" />
</div>
</div>
</ElPopover>
<span
v-else
class="workbench-todo__item-title-text"
:class="{
'workbench-todo__item-title-text--clickable': Boolean(
item.routeKey || item.approvalBizType || item.projectId
)
}"
@click="handleClickItem(item)"
>
{{ item.title }}
</span>
</h4>
<div class="workbench-todo__meta">
<span class="workbench-todo__source">{{ item.source }}</span>
</div>
<div
v-if="shouldShowTodoProgress(item)"
class="workbench-todo__progress"
:aria-label="`进度 ${getTodoProgress(item)}%`"
>
<span class="workbench-todo__progress-label">进度:</span>
<ElProgress
class="workbench-todo__progress-bar"
:percentage="getTodoProgress(item)"
:stroke-width="6"
:show-text="false"
/>
<span class="workbench-todo__progress-text">{{ getTodoProgress(item) }}%</span>
</div>
</div>
<div class="workbench-todo__trailing">
<div v-if="item.approvalBizType === 'overtime_application'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="审批">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openOvertimeDetail(item)">
<component :is="APPROVAL_ENTRY_ICON" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div v-else-if="item.approvalBizType === 'performance'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="审批">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPerformanceDetail(item)">
<component :is="APPROVAL_ENTRY_ICON" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div
v-else-if="isWorkReportApprovalBizType(item.approvalBizType)"
class="workbench-todo__actions"
@click.stop
>
<ElTooltip content="审批">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openWorkReportDetail(item)">
<component :is="APPROVAL_ENTRY_ICON" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div
v-else-if="item.category === 'task' && canReportTaskWorklog(item)"
class="workbench-todo__actions"
@click.stop
>
<ElTooltip content="填报">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openTaskWorklog(item)">
<component :is="PERSONAL_ACTION_ICONS.worklog" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip v-for="action in getTaskActions(item)" :key="action.actionCode" :content="action.actionName">
<ElButton
link
:type="getTaskActionButtonType(action.actionCode)"
class="workbench-todo__action-btn"
@click="handleTaskAction(item, action)"
>
<component :is="TASK_ACTION_ICONS[action.actionCode]" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<div v-else-if="item.category === 'personal'" class="workbench-todo__actions" @click.stop>
<ElTooltip content="详情">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPersonalDetail(item)">
<component :is="PERSONAL_ACTION_ICONS.detail" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="填报工时">
<ElButton link type="primary" class="workbench-todo__action-btn" @click="openPersonalWorklog(item)">
<component :is="PERSONAL_ACTION_ICONS.worklog" class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canCompletePersonalItem(item)" content="完成">
<ElButton link type="success" class="workbench-todo__action-btn" @click="openPersonalComplete(item)">
<component :is="PERSONAL_ACTION_ICONS.complete" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
<span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)">
{{ item.deadlineLabel }}
</span>
</div>
</article>
</div>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
</div>
<div class="workbench-todo__pager">
<ElPagination
v-if="filteredItems.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="filteredItems.length"
background
small
layout="prev, pager, next"
/>
</div>
<!-- append-to-body脱离 grid item 的 transform 容器,弹窗才能正常全屏居中 -->
<PersonalItemOperateDialog
v-model:visible="personalOperateVisible"
:operate-type="personalOperateType"
:row-data="personalOperateRow"
append-to-body
@submitted="handleAddSubmitted"
/>
<PersonalItemDetailDialog
v-model:visible="personalDetailVisible"
:row-data="personalDetailRow"
default-tab="worklog"
append-to-body
@changed="handlePersonalWorklogChanged"
/>
<PersonalItemStatusActionDialog
v-model:visible="personalStatusVisible"
:action="personalStatusAction"
append-to-body
@submit="handlePersonalStatusSubmit"
/>
<TaskStatusActionDialog
v-model:visible="taskStatusVisible"
:title="taskStatusActionTitle"
:action="taskStatusAction"
append-to-body
@submit="submitTaskStatusChange"
/>
<TaskWorklogDialog
v-model:visible="taskWorklogVisible"
:task="taskWorklogTask"
append-to-body
@changed="handleTaskWorklogChanged"
/>
<OvertimeApplicationDetailDialog
v-model:visible="overtimeDetailVisible"
:row-data="currentOvertimeApplication"
show-approval-actions
:action-loading="overtimeActionSubmitting"
append-to-body
@submit="handleOvertimeActionSubmit"
/>
<!-- 批量审批详情弹窗(左右箭头切换 + 通过/退回按钮 + 意见输入) -->
<OvertimeApplicationBatchDetailDialog
v-model:visible="batchDetailVisible"
:selected-ids="batchSelectedIds"
:rows="overtimeApprovalRows"
:action-loading="batchSubmitting"
append-to-body
@submit="handleBatchActionSubmit"
/>
<WorkReportPrototypePageDialog
v-model:visible="workReportDetailVisible"
mode="detail"
scene="approval"
:report-type="currentWorkReportType"
:row-data="currentWorkReport"
@submitted="handleWorkReportSubmitted"
/>
<PerformanceExcelEditorDrawer
v-model:visible="performanceDetailVisible"
:row-data="currentPerformanceSheet"
mode="view"
show-approval-footer
@start-approval="openCurrentPerformanceAction('confirm')"
/>
<PerformanceActionDialog
v-model:visible="performanceActionVisible"
:row-data="currentPerformanceSheet"
:action-type="currentPerformanceActionType"
selectable-action-type
@submitted="handlePerformanceActionSubmitted"
/>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-todo__tabs {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.workbench-todo__tabs-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.workbench-todo__add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: 1px solid rgb(14 116 144 / 60%);
border-radius: 999px;
background-color: rgb(240 253 250 / 80%);
color: rgb(14 116 144 / 96%);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: all 160ms ease;
}
.workbench-todo__add:hover {
background-color: rgb(14 116 144 / 96%);
border-color: rgb(14 116 144 / 96%);
color: white;
}
.workbench-todo__add-icon {
font-size: 14px;
}
.workbench-todo__tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 999px;
background-color: rgb(255 255 255 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 13px;
cursor: pointer;
transition: all 160ms ease;
}
.workbench-todo__tab:hover {
border-color: rgb(14 116 144 / 64%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__tab--active {
border-color: rgb(14 116 144 / 92%);
background-color: rgb(14 116 144 / 96%);
color: white;
}
.workbench-todo__tab--active:hover {
color: white;
}
.workbench-todo__tab-count {
padding: 1px 6px;
border-radius: 999px;
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 11px;
font-weight: 600;
}
.workbench-todo__tab--active .workbench-todo__tab-count {
background-color: rgb(255 255 255 / 22%);
color: white;
}
.workbench-todo__tab-dot {
position: absolute;
top: 4px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background-color: rgb(225 29 72 / 96%);
box-shadow: 0 0 0 2px rgb(255 255 255 / 96%);
}
.workbench-todo__tab--active .workbench-todo__tab-dot {
box-shadow: 0 0 0 2px rgb(14 116 144 / 96%);
}
.workbench-todo__filters {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.workbench-todo__filters-left {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.workbench-todo__sort {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-radius: 6px;
color: rgb(100 116 139 / 92%);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
transition: all 160ms ease;
}
.workbench-todo__sort:hover {
background-color: rgb(241 245 249 / 96%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__sort-icon {
font-size: 14px;
}
:deep(.el-dropdown-menu__item.is-active) {
color: var(--el-color-primary);
font-weight: 600;
}
.workbench-todo__filter {
padding: 3px 10px;
border: 1px dashed rgb(203 213 225 / 92%);
border-radius: 999px;
background-color: transparent;
color: rgb(100 116 139 / 92%);
font-size: 12px;
cursor: pointer;
transition: all 160ms ease;
}
.workbench-todo__filter:hover {
border-style: solid;
border-color: rgb(14 116 144 / 60%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__filter--active {
border-style: solid;
border-color: rgb(190 18 60 / 80%);
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.workbench-todo__filter--active:hover {
border-color: rgb(190 18 60 / 92%);
color: rgb(190 18 60 / 96%);
}
.workbench-todo__filter-count {
margin-left: 4px;
font-size: 11px;
font-weight: 700;
}
.workbench-todo__content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.workbench-todo__content :deep(.el-empty) {
margin: auto;
}
/* 批量操作栏 */
.workbench-todo__batch-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 14px;
margin-bottom: 10px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 12px;
background-color: rgb(248 250 252 / 96%);
font-size: 13px;
color: rgb(71 85 105 / 94%);
transition: all 160ms ease;
}
.workbench-todo__batch-bar--active {
border-color: rgb(14 116 144 / 40%);
background-color: rgb(240 253 250 / 80%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__batch-bar-left {
display: flex;
align-items: center;
gap: 10px;
}
.workbench-todo__batch-bar-right {
display: flex;
align-items: center;
gap: 6px;
}
.workbench-todo__batch-bar-count {
font-weight: 600;
}
.workbench-todo__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.workbench-todo__item {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.workbench-todo__item--selected {
border-color: rgb(14 116 144 / 60%);
background-color: rgb(240 253 250 / 90%);
}
.workbench-todo__leading {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.workbench-todo__category {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
flex: 0 0 auto;
}
.workbench-todo__category--sky {
background-color: rgb(224 242 254 / 96%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__category--emerald {
background-color: rgb(220 252 231 / 96%);
color: rgb(5 150 105 / 96%);
}
.workbench-todo__category--amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.workbench-todo__category--rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.workbench-todo__category--violet {
background-color: rgb(237 233 254 / 96%);
color: rgb(109 40 217 / 96%);
}
.workbench-todo__priority {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 6px;
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 96%);
font-size: 11px;
font-weight: 800;
}
.workbench-todo__priority--high {
background-color: rgb(254 226 226 / 96%);
color: rgb(220 38 38 / 96%);
}
.workbench-todo__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.workbench-todo__item-title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workbench-todo__item-title-text--clickable {
cursor: pointer;
transition: color 160ms ease;
}
.workbench-todo__item-title-text--clickable:hover {
color: rgb(14 116 144 / 96%);
text-decoration: underline;
}
:deep(.workbench-task-preview-popover) {
padding: 0;
overflow: hidden;
}
.workbench-task-preview {
background: var(--el-bg-color);
}
.workbench-task-preview__header {
display: flex;
align-items: center;
min-height: 48px;
padding: 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workbench-task-preview__body {
max-height: min(72vh, 760px);
padding: 18px;
overflow: auto;
}
.workbench-todo__meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.workbench-todo__source {
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workbench-todo__progress {
display: flex;
align-items: center;
gap: 6px;
width: min(100%, 260px);
margin-top: 1px;
}
.workbench-todo__progress-label {
flex: 0 0 auto;
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.workbench-todo__progress-bar {
flex: 1 1 auto;
min-width: 96px;
}
.workbench-todo__progress-text {
flex: 0 0 34px;
color: rgb(71 85 105 / 94%);
font-size: 12px;
font-weight: 600;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
}
.workbench-todo__progress :deep(.el-progress-bar__outer) {
background-color: rgb(226 232 240 / 96%);
}
.workbench-todo__trailing {
display: flex;
align-items: center;
gap: 12px;
}
.workbench-todo__actions {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.workbench-todo__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.workbench-todo__action-btn) {
min-width: auto;
height: auto;
padding: 3px;
line-height: 1;
}
.workbench-todo__deadline {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.workbench-todo__deadline--slate {
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
}
.workbench-todo__deadline--amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.workbench-todo__deadline--rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.workbench-todo__pager {
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 32px;
margin-top: 12px;
}
@media (width <= 600px) {
.workbench-todo__item {
grid-template-columns: 72px minmax(0, 1fr);
}
.workbench-todo__trailing {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>