refactor(workbench): 重构待办面板功能提升用户体验

- 替换原有时间桶过滤为分类标签页和截止时间筛选器
- 添加优先级排序功能,支持任务类别内按优先级排序
- 重构待办数据结构,新增创建时间和优先级字段
- 移除高优先级标记,统一使用优先级枚举值
- 添加个人事项创建对话框和相关操作功能
- 更新模拟数据以匹配新的数据结构和功能需求
- 优化列表排序逻辑,按创建时间升序排列,无截止时间排最后
- 为各类别待办项添加逾期状态标识和计数统计
- 实现分页加载,每页显示5条待办记录
- 更新样式类名以匹配新的逾期判断逻辑

refactor(project): 优化项目执行模块提升性能和可维护性

- 移除执行项点击切换功能相关的事件和方法
- 删除不再使用的select-execution事件发射器
- 移除执行标签的悬停效果和鼠标指针样式
- 重构任务表格视图,将日期格式化函数名称标准化
- 在跨执行模式下也显示进度列,统一界面布局
- 更新最近更新列宽度并调整日期格式显示
- 将默认页面大小从10增加到20以提高加载效率

feat(list): 统一日期格式化功能简化代码维护

- 将日期时间格式化函数重命名为更准确的date格式化
- 在产品列表和项目列表中统一使用新的日期格式化函数
- 移除秒数显示,仅保留年月日格式提高可读性

refactor(todo): 重构待办事项数据模型和过滤逻辑

- 重新定义待办事项分类类型,移除mention添加personal
- 新增主标签、截止时间筛选器和优先级类型定义
- 添加创建时间字段用于排序和显示
- 实现基于分类、截止时间和优先级的过滤函数
- 创建优先级权重映射用于排序算法
- 更新待办项构建函数以支持新的排序逻辑
- 修改逾期判断逻辑以适应新的数据结构
- 移除原有的高优先级字段,统一使用优先级枚举
- 添加优先级排序功能支持升序降序切换
- 重构排序算法,优先按创建时间,其次按截止时间排序

refactor(task): 清理任务模块中已废弃的功能

- 移除通过ID选择执行项的相关函数和事件处理器
- 删除任务卡片和表格中的执行项点击切换功能
- 更新任务工作区组件以移除废弃的事件监听
- 调整任务表格视图中进度条的样式和状态显示

refactor(components): 项目列表中添加进度条可视化组件

- 引入Element Plus进度条组件用于项目进度展示
- 在项目列表中添加进度列并实现进度条渲染
- 配置进度条样式包括内嵌文字、成功状态和边框圆角
- 调整进度列宽度以适应进度条显示需求

refactor(widgets): 整理工作台模块配置和清理冗余组件

- 从工作台模块注册中移除已废弃的myTicket组件
- 更新模块注释说明,明确myTicket已废弃的原因
- 删除不再使用的workbench-my-ticket.vue组件文件
- 更新模块总数注释从16个调整为15个
This commit is contained in:
2026-05-25 14:30:44 +08:00
parent e9214137c1
commit 3988eaf910
12 changed files with 566 additions and 341 deletions

View File

@@ -62,12 +62,12 @@ function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
}
function formatDateTime(value?: string | null) {
function formatDate(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
return dayjs(value).format('YYYY-MM-DD');
}
const statusNavMetas: StatusNavMeta[] = [
@@ -210,7 +210,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
formatter: row => formatDate(row.updateTime)
}
],
immediate: false

View File

@@ -1,6 +1,6 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import { ElButton, ElProgress, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
@@ -55,12 +55,12 @@ function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
}
function formatDateTime(value?: string | null) {
function formatDate(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
return dayjs(value).format('YYYY-MM-DD');
}
const searchParams = reactive(getInitSearchParams());
@@ -170,9 +170,20 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
{
prop: 'progressRate',
label: '进度',
width: 100,
align: 'center',
formatter: () => '--'
width: 160,
formatter: row => {
const percentage = row.progressRate ?? 0;
return (
<div style="padding: 0 8px;">
<ElProgress
percentage={percentage}
status={percentage >= 100 ? 'success' : undefined}
stroke-width={18}
text-inside
/>
</div>
);
}
},
{
prop: 'statusCode',
@@ -188,7 +199,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
formatter: row => formatDate(row.updateTime)
}
],
immediate: false

View File

@@ -87,7 +87,6 @@ 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[]>([]);
@@ -305,30 +304,6 @@ function handleSelectExecution(row: Api.Project.ProjectExecution) {
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();
@@ -590,7 +565,6 @@ watch(
:all-count="allTasksCount"
:show-all="showAllPerspective"
@execution-changed="handleExecutionChangedByTask"
@select-execution="handleSelectExecutionById"
@select-perspective="handleSelectPerspective"
/>
</div>

View File

@@ -38,7 +38,6 @@ 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,
@@ -339,8 +338,7 @@ onBeforeUnmount(() => {
type="info"
effect="plain"
class="task-board-card-item__exec-tag"
:title="task.executionName || '切换到该执行'"
@click.stop="emit('select-execution', task.executionId)"
:title="task.executionName || ''"
>
{{ task.executionName || '未命名执行' }}
</ElTag>
@@ -545,14 +543,9 @@ onBeforeUnmount(() => {
}
.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;

View File

@@ -4,7 +4,7 @@ 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 { formatDate, formatDateRange, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions';
defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -15,6 +15,7 @@ interface Props {
pagination: Partial<PaginationProps & Record<string, any>>;
/**
* 跨执行模式:显示「所属执行 pill」、「角色」列隐藏「父任务」、「实际周期」、「最近更新」列。
* 「进度」列在两种模式下都显示。
* 由父组件根据视角类型传入:跨执行视角(my/all) = true单执行视角 = false。
*/
crossExecutionMode?: boolean;
@@ -25,7 +26,6 @@ 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,
@@ -113,8 +113,7 @@ function handleSizeChange(pageSize: number) {
type="info"
effect="plain"
class="task-table-exec-tag"
:title="row.executionName || '切换到该执行'"
@click.stop="emit('select-execution', row.executionId)"
:title="row.executionName || ''"
>
{{ row.executionName || '未命名执行' }}
</ElTag>
@@ -145,10 +144,15 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn v-if="!crossExecutionMode" label="父任务" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn>
<ElTableColumn v-if="!crossExecutionMode" label="进度" width="160">
<ElTableColumn label="进度" width="160">
<template #default="{ row }">
<div class="task-table-progress">
<ElProgress :percentage="row.progressRate" :stroke-width="18" text-inside />
<ElProgress
:percentage="row.progressRate"
:status="row.progressRate >= 100 ? 'success' : undefined"
:stroke-width="18"
text-inside
/>
</div>
</template>
</ElTableColumn>
@@ -161,8 +165,8 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn v-if="!crossExecutionMode" label="实际周期" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateRange(row.actualStartDate, row.actualEndDate) }}</template>
</ElTableColumn>
<ElTableColumn v-if="!crossExecutionMode" label="最近更新" width="170">
<template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
<ElTableColumn v-if="!crossExecutionMode" label="最近更新" width="130">
<template #default="{ row }">{{ formatDate(row.updateTime) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="210" fixed="right" align="center" class-name="task-operate-column">
<template #default="{ row }">
@@ -206,14 +210,9 @@ 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;

View File

@@ -77,7 +77,6 @@ interface Props {
interface Emits {
(e: 'executionChanged'): void;
(e: 'selectExecution', executionId: string): void;
(e: 'selectPerspective', type: 'my' | 'all'): void;
}
@@ -165,7 +164,7 @@ const quickFilterChips = computed<QuickFilterChip[]>(() => [
// 「我参与的」视角下展示 chip,数字跟着 scope(全部/状态/锚定执行)变
const showQuickFilters = computed(() => props.viewContext.type === 'my');
const PAGE_SIZE_DEFAULT = 10;
const PAGE_SIZE_DEFAULT = 20;
const pageNo = ref(1);
const pageSize = ref(PAGE_SIZE_DEFAULT);
@@ -418,10 +417,6 @@ async function refreshTableData(resetToFirstPage = false) {
await (resetToFirstPage ? getDataByPage(1) : getData());
}
function handleSelectExecutionFromRow(targetExecutionId: string) {
emit('selectExecution', targetExecutionId);
}
async function handleCreate() {
if (!props.execution) {
window.$message?.warning('请先选择执行项');
@@ -882,7 +877,6 @@ defineExpose({
@report="handleReport"
@status-action="handleStatusAction"
@delete="openDeleteTaskDialog"
@select-execution="handleSelectExecutionFromRow"
/>
<TaskBoardView
@@ -897,7 +891,6 @@ defineExpose({
@report="handleReport"
@status-action="handleStatusAction"
@delete="openDeleteTaskDialog"
@select-execution="handleSelectExecutionFromRow"
/>
<TaskOperateDialog

View File

@@ -12,8 +12,7 @@ export type WorkbenchModuleKey =
// 重构key 沿用,组件内容重写)
| 'myTask' // A2 · 我的今日(原"我的任务"
| 'teamTodo' // C11 · 团队任务看板(原"团队待办汇总"
// 新增 16 个(蓝图 2026-05-22
| 'myTicket' // A3 · 我负责的工单(工单待开发,先 mock
// 新增 15 个(蓝图 2026-05-22,原 A3 myTicket 已废弃:与"我的待办 → 工单"重复且工单业务未上线
| 'mentions' // A4 · @我的提及
| 'approval' // A5 · 待审批(管理者)
| 'worklogReminder' // A6 · 工时填报提醒(动作型)
@@ -130,17 +129,7 @@ const registry: WorkbenchModuleMeta[] = [
defaultOrder: 30
},
// === 新增 16 个:默认全部 hidden进 widget 库待用户挑(避免一上来挤爆工作台) ===
{
key: 'myTicket',
component: placeholder,
displayName: '我负责的工单',
icon: 'mdi:ticket-confirmation-outline',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 20
},
// === 新增 15 个:默认全部 hidden进 widget 库待用户挑(避免一上来挤爆工作台) ===
{
key: 'mentions',
component: placeholder,

View File

@@ -2,9 +2,13 @@ import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'review' | 'task' | 'ticket' | 'mention';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'review';
export type WorkbenchTodoTimeBucket = 'all' | 'today' | 'week' | 'overdue';
export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory;
export type WorkbenchTodoDeadlineFilter = 'overdue' | 'today' | 'week' | null;
export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export type WorkbenchProjectStatus = 'active' | 'preview' | 'paused';
@@ -72,14 +76,16 @@ export interface WorkbenchTodoItemSource {
id: string;
category: WorkbenchTodoCategory;
title: string;
/** 创建时间ISO 字符串。列表默认按这个升序 */
createdTime: string;
/** 截止时间ISO 字符串 */
deadline: string | null;
/** 来源(提交人/项目名/工单号) */
source: string;
/** 优先级,用于前端排序与高亮 */
priority?: WorkbenchTodoPriority;
/** 是否逾期 */
overdue?: boolean;
/** 是否高优先级 */
highPriority?: boolean;
/** 跳转路由 key可选未配则不跳转 */
routeKey?: string;
}
@@ -140,10 +146,16 @@ const todoCategoryMeta: Record<
WorkbenchTodoCategory,
{ label: string; tone: WorkbenchTodoItem['categoryTone']; icon: string }
> = {
review: { label: '需求评审', tone: 'sky', icon: 'mdi:file-search-outline' },
task: { label: '任务', tone: 'emerald', icon: 'mdi:checkbox-marked-circle-outline' },
ticket: { label: '工单', tone: 'amber', icon: 'mdi:ticket-confirmation-outline' },
mention: { label: '@ 提及', tone: 'violet', icon: 'mdi:at' }
personal: { label: '个人事项', tone: 'violet', icon: 'mdi:notebook-edit-outline' },
review: { label: '待评审', tone: 'sky', icon: 'mdi:file-search-outline' }
};
const todoPriorityWeight: Record<WorkbenchTodoPriority, number> = {
high: 0,
mid: 1,
low: 2
};
const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], WorkbenchActivityItem['tone']> = {
@@ -282,9 +294,12 @@ export function buildWorkbenchKpiCards(source: WorkbenchKpiSource): WorkbenchKpi
export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource[]): WorkbenchTodoItem[] {
return [...source]
.sort((left, right) => {
const leftValue = left.deadline ? dayjs(left.deadline).valueOf() : Number.POSITIVE_INFINITY;
const rightValue = right.deadline ? dayjs(right.deadline).valueOf() : Number.POSITIVE_INFINITY;
return leftValue - rightValue;
const leftCreated = dayjs(left.createdTime).valueOf();
const rightCreated = dayjs(right.createdTime).valueOf();
if (leftCreated !== rightCreated) return leftCreated - rightCreated;
const leftDeadline = left.deadline ? dayjs(left.deadline).valueOf() : Number.POSITIVE_INFINITY;
const rightDeadline = right.deadline ? dayjs(right.deadline).valueOf() : Number.POSITIVE_INFINITY;
return leftDeadline - rightDeadline;
})
.map(item => {
const meta = todoCategoryMeta[item.category];
@@ -298,12 +313,42 @@ export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource
});
}
export function filterWorkbenchTodoItems(items: readonly WorkbenchTodoItem[], bucket: WorkbenchTodoTimeBucket) {
if (bucket === 'all') return [...items];
if (bucket === 'overdue') return items.filter(item => item.remainingDays !== null && item.remainingDays < 0);
if (bucket === 'today') return items.filter(item => item.remainingDays === 0);
// bucket === 'week'
return items.filter(item => item.remainingDays !== null && item.remainingDays >= 0 && item.remainingDays <= 7);
export function filterWorkbenchTodoItemsByCategory(items: readonly WorkbenchTodoItem[], tab: WorkbenchTodoMainTab) {
if (tab === 'all') return [...items];
return items.filter(item => item.category === tab);
}
export function filterWorkbenchTodoItemsByDeadline(
items: readonly WorkbenchTodoItem[],
filter: WorkbenchTodoDeadlineFilter
) {
if (!filter) return [...items];
if (filter === 'overdue') return items.filter(item => isOverdue(item));
if (filter === 'today') return items.filter(item => item.remainingDays === 0);
// filter === 'week':含今日,含逾期一起暴露给用户处理
return items.filter(item => item.remainingDays !== null && item.remainingDays <= 7);
}
export function isWorkbenchTodoOverdue(item: WorkbenchTodoItem) {
return isOverdue(item);
}
function isOverdue(item: WorkbenchTodoItem) {
if (item.overdue) return true;
return item.remainingDays !== null && item.remainingDays < 0;
}
export function sortWorkbenchTodoItemsByPriority(
items: readonly WorkbenchTodoItem[],
direction: 'asc' | 'desc' = 'desc'
) {
const factor = direction === 'desc' ? 1 : -1;
return [...items].sort((left, right) => {
const leftWeight = todoPriorityWeight[left.priority ?? 'low'];
const rightWeight = todoPriorityWeight[right.priority ?? 'low'];
if (leftWeight !== rightWeight) return (leftWeight - rightWeight) * factor;
return dayjs(left.createdTime).valueOf() - dayjs(right.createdTime).valueOf();
});
}
export function buildWorkbenchActivityItems(source: readonly WorkbenchActivityItemSource[]): WorkbenchActivityItem[] {

View File

@@ -23,8 +23,7 @@ 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';
// 新增 15 个(蓝图 2026-05-22,原 A3 myTicket 已废弃
import WorkbenchMentions from './modules/workbench-mentions.vue';
import WorkbenchApproval from './modules/workbench-approval.vue';
import WorkbenchWorklogReminder from './modules/workbench-worklog-reminder.vue';
@@ -53,8 +52,7 @@ registerModuleComponent('shortcut', WorkbenchShortcut);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('favorite', WorkbenchFavorite);
// 新增 16
registerModuleComponent('myTicket', WorkbenchMyTicket);
// 新增 15
registerModuleComponent('mentions', WorkbenchMentions);
registerModuleComponent('approval', WorkbenchApproval);
registerModuleComponent('worklogReminder', WorkbenchWorklogReminder);

View File

@@ -35,70 +35,124 @@ export const workbenchKpiMock = {
export const workbenchTodoMock = [
{
id: 'todo-1',
category: 'review',
title: '订单导出 V2 需求评审',
deadline: iso(now.add(2, 'day').hour(18).minute(0)),
source: '张三 · 收银台 V3',
highPriority: true,
routeKey: 'product_list'
},
{
id: 'todo-2',
category: 'task',
title: '支付回调接口联调',
deadline: iso(now.add(1, 'day').hour(17).minute(30)),
title: '支付回调接口联调遗留问题处理',
createdTime: iso(now.subtract(7, 'day').hour(9).minute(20)),
deadline: iso(now.subtract(1, 'day').hour(17).minute(30)),
source: '收银台 V3 · 后端联调',
priority: 'high',
overdue: true,
routeKey: 'project_list'
},
{
id: 'todo-3',
id: 'todo-2',
category: 'ticket',
title: '工单 #1024 等待我处理',
deadline: iso(now.hour(20).minute(0)),
source: '王五 · 客户反馈',
title: '工单 #1018 用户登录异常',
createdTime: iso(now.subtract(6, 'day').hour(11).minute(0)),
deadline: iso(now.subtract(2, 'day').hour(10).minute(0)),
source: '客户支持 · 紧急',
priority: 'mid',
overdue: true,
routeKey: 'ticket'
},
{
id: 'todo-4',
category: 'mention',
title: '需求「会员等级」中 @ 了你',
deadline: null,
source: '李四 · 会员中心',
id: 'todo-3',
category: 'review',
title: '订单导出 V2 需求评审',
createdTime: iso(now.subtract(5, 'day').hour(14).minute(40)),
deadline: iso(now.add(3, 'day').hour(18).minute(0)),
source: '需求 · 收银台 V3',
priority: 'high',
routeKey: 'product_list'
},
{
id: 'todo-4',
category: 'personal',
title: '提交昨日工时与本周计划',
createdTime: iso(now.subtract(4, 'day').hour(8).minute(30)),
deadline: iso(now.hour(18).minute(0)),
source: '个人事项 · 工时',
priority: 'mid',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-5',
category: 'task',
title: '订单中心结算文档评审',
deadline: iso(now.add(4, 'day').hour(15).minute(0)),
source: '订单中心 · 文档',
title: '会员等级提示文案最终校对',
createdTime: iso(now.subtract(3, 'day').hour(10).minute(10)),
deadline: iso(now.hour(20).minute(0)),
source: '会员中心 · 文案',
priority: 'high',
routeKey: 'project_list'
},
{
id: 'todo-6',
category: 'review',
title: 'API 返回结构调整评审',
deadline: iso(now.subtract(1, 'day').hour(18).minute(0)),
source: '赵六 · 收银台 V3',
overdue: true,
highPriority: true,
routeKey: 'product_list'
category: 'ticket',
title: '工单 #1024 客户反馈待处理',
createdTime: iso(now.subtract(3, 'day').hour(15).minute(0)),
deadline: iso(now.add(2, 'day').hour(17).minute(0)),
source: '王五 · 客户反馈',
priority: 'mid',
routeKey: 'ticket'
},
{
id: 'todo-7',
category: 'task',
title: '会员等级提示文案校对',
deadline: iso(now.add(3, 'day').hour(11).minute(0)),
source: '会员中心 · 文案',
routeKey: 'project_list'
category: 'personal',
title: '复盘上周交付遗留并归档',
createdTime: iso(now.subtract(2, 'day').hour(9).minute(0)),
deadline: iso(now.add(4, 'day').hour(18).minute(0)),
source: '个人事项 · 复盘',
priority: 'low',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-8',
category: 'task',
title: '订单中心结算文档评审',
createdTime: iso(now.subtract(2, 'day').hour(13).minute(20)),
deadline: iso(now.add(5, 'day').hour(15).minute(0)),
source: '订单中心 · 文档',
priority: 'mid',
routeKey: 'project_list'
},
{
id: 'todo-9',
category: 'review',
title: 'API 返回结构调整评审',
createdTime: iso(now.subtract(1, 'day').hour(17).minute(0)),
deadline: iso(now.add(12, 'day').hour(18).minute(0)),
source: '需求 · 收银台 V3',
priority: 'mid',
routeKey: 'product_list'
},
{
id: 'todo-10',
category: 'personal',
title: '安排下周外出培训行程',
createdTime: iso(now.subtract(1, 'day').hour(19).minute(40)),
deadline: iso(now.add(10, 'day').hour(12).minute(0)),
source: '个人事项 · 行程',
priority: 'low',
routeKey: 'personal-center_my-item'
},
{
id: 'todo-11',
category: 'task',
title: '会员中心首页骨架屏改造',
createdTime: iso(now.subtract(20, 'hour')),
deadline: iso(now.add(12, 'day').hour(18).minute(0)),
source: '会员中心 · 前端',
priority: 'low',
routeKey: 'project_list'
},
{
id: 'todo-12',
category: 'ticket',
title: '工单 #1018 用户登录异常',
deadline: iso(now.add(1, 'day').hour(10).minute(0)),
source: '客户支持 · 紧急',
highPriority: true,
title: '工单 #1031 提示信息文案优化',
createdTime: iso(now.subtract(8, 'hour')),
deadline: iso(now.add(14, 'day').hour(17).minute(0)),
source: '客户支持 · 普通',
priority: 'low',
routeKey: 'ticket'
}
] satisfies WorkbenchTodoItemSource[];

View File

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

View File

@@ -1,16 +1,23 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import type { RouteKey } from '@elegant-router/types';
import { useRouterPush } from '@/hooks/common/router';
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
import {
type WorkbenchTodoDeadlineFilter,
type WorkbenchTodoItem,
type WorkbenchTodoTimeBucket,
type WorkbenchTodoMainTab,
buildWorkbenchTodoItems,
filterWorkbenchTodoItems
filterWorkbenchTodoItemsByCategory,
filterWorkbenchTodoItemsByDeadline,
isWorkbenchTodoOverdue,
sortWorkbenchTodoItemsByPriority
} from '../homepage';
import { workbenchTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
type SortKey = 'created' | 'priority' | 'deadline';
defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
@@ -27,25 +34,129 @@ defineEmits<{
const { routerPushByKey } = useRouterPush();
const activeBucket = ref<WorkbenchTodoTimeBucket>('all');
const PAGE_SIZE = 5;
const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
const activeTab = ref<WorkbenchTodoMainTab>('all');
const activeDeadlineFilter = ref<WorkbenchTodoDeadlineFilter>(null);
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: 'today', label: '今日' },
{ key: 'week', label: '本周' },
{ key: 'overdue', label: '逾期' }
{ key: 'task', label: '任务' },
{ key: 'ticket', label: '工单' },
{ key: 'personal', label: '个人事项' },
{ key: 'review', label: '待评审' }
];
const items = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const deadlineFilters: Array<{ key: Exclude<WorkbenchTodoDeadlineFilter, null>; label: string }> = [
{ key: 'overdue', label: '已逾期' },
{ key: 'today', label: '今日到期' },
{ key: 'week', label: '本周到期' }
];
const bucketCounts = computed(() => ({
all: items.value.length,
today: filterWorkbenchTodoItems(items.value, 'today').length,
week: filterWorkbenchTodoItems(items.value, 'week').length,
overdue: filterWorkbenchTodoItems(items.value, 'overdue').length
}));
const allItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const filteredItems = computed(() => filterWorkbenchTodoItems(items.value, activeBucket.value));
const addDialogVisible = ref(false);
function handleOpenAdd() {
addDialogVisible.value = true;
}
function handleAddSubmitted() {
activeTab.value = 'personal';
activeDeadlineFilter.value = null;
}
const tabCounts = computed(() => {
const counts: Record<WorkbenchTodoMainTab, number> = {
all: allItems.value.length,
task: 0,
ticket: 0,
personal: 0,
review: 0
};
allItems.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,
review: 0
};
allItems.value.forEach(item => {
if (!isWorkbenchTodoOverdue(item)) return;
map.all += 1;
map[item.category] += 1;
});
return map;
});
const itemsInTab = computed(() => filterWorkbenchTodoItemsByCategory(allItems.value, activeTab.value));
const filteredItems = computed(() => filterWorkbenchTodoItemsByDeadline(itemsInTab.value, activeDeadlineFilter.value));
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;
});
function handleSelectTab(key: WorkbenchTodoMainTab) {
if (activeTab.value === key) return;
activeTab.value = key;
activeDeadlineFilter.value = null;
if (key !== 'task' && activeSort.value === 'priority') {
activeSort.value = 'deadline';
}
}
function handleSelectDeadlineFilter(key: Exclude<WorkbenchTodoDeadlineFilter, null>) {
activeDeadlineFilter.value = activeDeadlineFilter.value === key ? null : key;
}
function handleSelectSort(key: SortKey) {
activeSort.value = key;
}
function handleClickItem(item: WorkbenchTodoItem) {
if (!item.routeKey) return;
@@ -53,9 +164,7 @@ function handleClickItem(item: WorkbenchTodoItem) {
}
function getDeadlineToneClass(item: WorkbenchTodoItem) {
if (item.overdue || (item.remainingDays !== null && item.remainingDays < 0)) {
return 'workbench-todo__deadline--rose';
}
if (isWorkbenchTodoOverdue(item)) return 'workbench-todo__deadline--rose';
if (item.remainingDays === 0) return 'workbench-todo__deadline--amber';
return 'workbench-todo__deadline--slate';
}
@@ -71,61 +180,165 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="workbench-todo__tabs">
<button
v-for="bucket in buckets"
:key="bucket.key"
type="button"
class="workbench-todo__tab"
:class="{ 'workbench-todo__tab--active': activeBucket === bucket.key }"
@click="activeBucket = bucket.key"
>
<span>{{ bucket.label }}</span>
<span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
<div class="workbench-todo__tabs-group">
<ElTooltip
v-for="tab in mainTabs"
: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 v-if="filteredItems.length" class="workbench-todo__list">
<article
v-for="item in filteredItems"
:key="item.id"
class="workbench-todo__item"
:class="{ 'workbench-todo__item--clickable': Boolean(item.routeKey) }"
@click="handleClickItem(item)"
>
<div class="workbench-todo__leading">
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }}
</span>
<span v-if="item.highPriority" class="workbench-todo__priority"></span>
</div>
<div class="workbench-todo__filters">
<div 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 class="workbench-todo__body">
<h4 class="workbench-todo__item-title">{{ item.title }}</h4>
<div class="workbench-todo__meta">
<span class="workbench-todo__source">{{ item.source }}</span>
</div>
</div>
<div class="workbench-todo__trailing">
<span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)">
{{ item.deadlineLabel }}
</span>
</div>
</article>
<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>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
<div class="workbench-todo__content">
<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--clickable': Boolean(item.routeKey) }"
@click="handleClickItem(item)"
>
<div class="workbench-todo__leading">
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }}
</span>
<span v-if="item.priority === 'high'" class="workbench-todo__priority"></span>
</div>
<div class="workbench-todo__body">
<h4 class="workbench-todo__item-title">{{ item.title }}</h4>
<div class="workbench-todo__meta">
<span class="workbench-todo__source">{{ item.source }}</span>
</div>
</div>
<div class="workbench-todo__trailing">
<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>
<PersonalItemOperateDialog
v-model:visible="addDialogVisible"
operate-type="add"
:row-data="null"
@submitted="handleAddSubmitted"
/>
</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;
margin-bottom: 14px;
}
.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;
@@ -168,6 +381,101 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
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__content {
min-height: 400px;
display: flex;
flex-direction: column;
}
.workbench-todo__content :deep(.el-empty) {
margin: auto;
}
.workbench-todo__list {
display: flex;
flex-direction: column;
@@ -313,6 +621,14 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
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: auto minmax(0, 1fr);