refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题

This commit is contained in:
2026-05-21 21:42:23 +08:00
parent 28d597d91e
commit ba328e02bb
68 changed files with 3329 additions and 644 deletions

View File

@@ -0,0 +1,28 @@
import type { LayoutStorage } from './layout-storage';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
const KEY_PREFIX = 'rdms-workbench-layout';
function buildKey(userId: string) {
return `${KEY_PREFIX}-${userId}`;
}
export class LocalStorageAdapter implements LayoutStorage {
// eslint-disable-next-line class-methods-use-this
async load(userId: string): Promise<WorkbenchLayout | null> {
try {
const raw = window.localStorage.getItem(buildKey(userId));
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkbenchLayout;
if (parsed?.version !== WORKBENCH_LAYOUT_VERSION) return null;
return parsed;
} catch {
return null;
}
}
// eslint-disable-next-line class-methods-use-this
async save(userId: string, layout: WorkbenchLayout): Promise<void> {
window.localStorage.setItem(buildKey(userId), JSON.stringify(layout));
}
}

View File

@@ -0,0 +1,6 @@
import type { WorkbenchLayout } from './workbench-layout-types';
export interface LayoutStorage {
load(userId: string): Promise<WorkbenchLayout | null>;
save(userId: string, layout: WorkbenchLayout): Promise<void>;
}

View File

@@ -0,0 +1,158 @@
import { computed, ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { type WorkbenchColumnId, type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules';
import { buildDefaultLayout } from './workbench-layout-default';
import type { LayoutStorage } from './layout-storage';
import { LocalStorageAdapter } from './layout-storage-local';
import { reconcileLayout } from './workbench-layout-reconcile';
import type { WorkbenchLayout } from './workbench-layout-types';
export type WorkbenchMode = 'normal' | 'editing';
interface UseWorkbenchLayoutOptions {
userId: string;
storage?: LayoutStorage;
}
export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
const { getAllModules } = useWorkbenchModules();
const storage = options.storage ?? new LocalStorageAdapter();
const layout = ref<WorkbenchLayout>(buildDefaultLayout(getAllModules()));
const mode = ref<WorkbenchMode>('normal');
const dirty = ref(false);
const saving = ref(false);
const error = ref<Error | null>(null);
let snapshotBeforeEdit: WorkbenchLayout | null = null;
async function load() {
const fromStorage = await storage.load(options.userId);
layout.value = reconcileLayout(fromStorage ?? buildDefaultLayout(getAllModules()), getAllModules());
}
const persist = useDebounceFn(async () => {
saving.value = true;
error.value = null;
try {
await storage.save(options.userId, layout.value);
} catch (err) {
error.value = err as Error;
} finally {
saving.value = false;
}
}, 500);
function markDirty() {
if (mode.value === 'editing') {
dirty.value = true;
} else {
// 非编辑态写(如折叠)直接落盘
persist();
}
}
function enterEditing() {
snapshotBeforeEdit = JSON.parse(JSON.stringify(layout.value));
mode.value = 'editing';
dirty.value = false;
}
async function saveEditing() {
saving.value = true;
try {
await storage.save(options.userId, layout.value);
mode.value = 'normal';
dirty.value = false;
snapshotBeforeEdit = null;
} catch (err) {
error.value = err as Error;
} finally {
saving.value = false;
}
}
function cancelEditing() {
if (snapshotBeforeEdit) {
layout.value = snapshotBeforeEdit;
}
mode.value = 'normal';
dirty.value = false;
snapshotBeforeEdit = null;
}
function hideModule(key: WorkbenchModuleKey) {
for (const col of layout.value.columns) {
col.modules = col.modules.filter(k => k !== key);
}
if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key);
markDirty();
}
function showModule(key: WorkbenchModuleKey, columnId: WorkbenchColumnId = 'left') {
layout.value.hidden = layout.value.hidden.filter(k => k !== key);
const target = layout.value.columns.find(c => c.id === columnId);
if (target && !target.modules.includes(key)) target.modules.push(key);
markDirty();
}
function setColumnModules(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
const target = layout.value.columns.find(c => c.id === columnId);
if (target) target.modules = modules;
markDirty();
}
function toggleCollapse(key: WorkbenchModuleKey) {
if (layout.value.collapsed.includes(key)) {
layout.value.collapsed = layout.value.collapsed.filter(k => k !== key);
} else {
layout.value.collapsed.push(key);
}
markDirty();
}
function updateModuleSettings<K extends keyof WorkbenchLayout['settings']>(
key: K,
value: WorkbenchLayout['settings'][K]
) {
layout.value.settings = { ...layout.value.settings, [key]: value };
markDirty();
}
async function resetToDefault() {
layout.value = buildDefaultLayout(getAllModules());
mode.value = 'normal';
dirty.value = false;
snapshotBeforeEdit = null;
await storage.save(options.userId, layout.value);
}
const isCollapsed = (key: WorkbenchModuleKey) => layout.value.collapsed.includes(key);
const hiddenMetas = computed(() => {
const allMeta = getAllModules();
return layout.value.hidden
.map(k => allMeta.find(m => m.key === k))
.filter((m): m is NonNullable<typeof m> => Boolean(m));
});
return {
layout,
mode,
dirty,
saving,
error,
hiddenMetas,
isCollapsed,
load,
enterEditing,
saveEditing,
cancelEditing,
hideModule,
showModule,
setColumnModules,
toggleCollapse,
updateModuleSettings,
resetToDefault
};
}

View File

@@ -0,0 +1,160 @@
import type { Component } from 'vue';
import { markRaw, shallowRef } from 'vue';
export type WorkbenchModuleKey =
| 'kpi'
| 'myTodo'
| 'myTask'
| 'myRequirement'
| 'myProject'
| 'activity'
| 'shortcut'
| 'teamTodo'
| 'projectHealth'
| 'progressChart'
| 'favorite';
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool';
export type WorkbenchColumnId = 'left' | 'right';
export interface WorkbenchModuleMeta {
key: WorkbenchModuleKey;
component: Component;
displayName: string;
icon: string;
category: WorkbenchModuleCategory;
defaultVisible: boolean;
defaultColumn: WorkbenchColumnId;
defaultOrder: number;
}
const placeholder = markRaw({ render: () => null });
const registry: WorkbenchModuleMeta[] = [
{
key: 'kpi',
component: placeholder,
displayName: 'KPI 速览',
icon: 'mdi:view-dashboard-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 1
},
{
key: 'myTodo',
component: placeholder,
displayName: '我的待办',
icon: 'mdi:clipboard-text-clock-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 2
},
{
key: 'myTask',
component: placeholder,
displayName: '我的任务',
icon: 'mdi:checkbox-marked-circle-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 3
},
{
key: 'myRequirement',
component: placeholder,
displayName: '我的需求',
icon: 'mdi:file-document-multiple-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 4
},
{
key: 'myProject',
component: placeholder,
displayName: '我参与的项目',
icon: 'mdi:briefcase-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 1
},
{
key: 'activity',
component: placeholder,
displayName: '最近动态',
icon: 'mdi:timeline-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 2
},
{
key: 'shortcut',
component: placeholder,
displayName: '快捷入口',
icon: 'mdi:rocket-launch-outline',
category: 'tool',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 3
},
{
key: 'teamTodo',
component: placeholder,
displayName: '团队待办汇总',
icon: 'mdi:account-group-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 4
},
{
key: 'projectHealth',
component: placeholder,
displayName: '项目健康度',
icon: 'mdi:heart-pulse',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 5
},
{
key: 'progressChart',
component: placeholder,
displayName: '跨项目进度图',
icon: 'mdi:chart-bar',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 6
},
{
key: 'favorite',
component: placeholder,
displayName: '我的收藏',
icon: 'mdi:star-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 7
}
];
const registryRef = shallowRef(registry);
export function useWorkbenchModules() {
function getAllModules() {
return registryRef.value;
}
function getModuleMeta(key: WorkbenchModuleKey) {
return registryRef.value.find(m => m.key === key);
}
function registerModuleComponent(key: WorkbenchModuleKey, component: Component) {
const target = registryRef.value.find(m => m.key === key);
if (target) target.component = markRaw(component);
}
return { getAllModules, getModuleMeta, registerModuleComponent };
}

View File

@@ -0,0 +1,30 @@
import type { WorkbenchModuleMeta } from './use-workbench-modules';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const left = modules
.filter(m => m.defaultVisible && m.defaultColumn === 'left')
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
const right = modules
.filter(m => m.defaultVisible && m.defaultColumn === 'right')
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
const hidden = modules
.filter(m => !m.defaultVisible)
.sort((a, b) => a.defaultOrder - b.defaultOrder)
.map(m => m.key);
return {
version: WORKBENCH_LAYOUT_VERSION,
columns: [
{ id: 'left', modules: left },
{ id: 'right', modules: right }
],
hidden,
collapsed: [],
settings: {}
};
}

View File

@@ -0,0 +1,31 @@
import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules';
import type { WorkbenchLayout } from './workbench-layout-types';
/**
* 把存量布局与当前模块注册中心对齐。
* - 注册中心存在但布局未含的 key按 defaultVisible 进 columns 或 hidden
* - 布局含但注册中心已删除的 key丢弃
*/
export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout {
const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key));
const filterKnown = (list: WorkbenchModuleKey[]) => list.filter(k => knownKeys.has(k));
const columns = layout.columns.map(c => ({ id: c.id, modules: filterKnown(c.modules) }));
const hidden = filterKnown(layout.hidden);
const collapsed = filterKnown(layout.collapsed);
const appearKeys = new Set<WorkbenchModuleKey>([...columns.flatMap(c => c.modules), ...hidden]);
for (const m of modules) {
if (!appearKeys.has(m.key)) {
if (m.defaultVisible) {
const target = columns.find(c => c.id === m.defaultColumn) ?? columns[0];
target.modules.push(m.key);
} else {
hidden.push(m.key);
}
}
}
return { ...layout, columns, hidden, collapsed };
}

View File

@@ -0,0 +1,22 @@
import type { WorkbenchColumnId, WorkbenchModuleKey } from './use-workbench-modules';
export const WORKBENCH_LAYOUT_VERSION = 1;
export interface WorkbenchShortcutSettings {
/** 用户在快捷入口里选了哪些菜单 key */
menuKeys: string[];
}
export interface WorkbenchModuleSettings {
shortcut?: WorkbenchShortcutSettings;
/** 后续每模块可加自定义设置 */
[key: string]: unknown;
}
export interface WorkbenchLayout {
version: typeof WORKBENCH_LAYOUT_VERSION;
columns: Array<{ id: WorkbenchColumnId; modules: WorkbenchModuleKey[] }>;
hidden: WorkbenchModuleKey[];
collapsed: WorkbenchModuleKey[];
settings: WorkbenchModuleSettings;
}

View File

@@ -345,3 +345,138 @@ export function getTodayLabel() {
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`;
}
export type WorkbenchMyTaskBucket = 'today' | 'week' | 'overdue' | 'all';
export interface WorkbenchMyTaskItemSource {
id: string;
title: string;
statusCode: string;
statusLabel: string;
executionName: string;
projectName: string;
priority: 'high' | 'mid' | 'low';
deadline: string | null;
}
export interface WorkbenchMyTaskItem extends Omit<WorkbenchMyTaskItemSource, 'deadline'> {
deadlineLabel: string;
remainingDays: number | null;
overdue: boolean;
}
export function buildWorkbenchMyTaskItems(source: readonly WorkbenchMyTaskItemSource[]): WorkbenchMyTaskItem[] {
return [...source]
.sort((a, b) => {
const av = a.deadline ? dayjs(a.deadline).valueOf() : Number.POSITIVE_INFINITY;
const bv = b.deadline ? dayjs(b.deadline).valueOf() : Number.POSITIVE_INFINITY;
return av - bv;
})
.map(item => {
const remaining = getRemainingDays(item.deadline);
return {
...item,
deadlineLabel: formatDeadline(item.deadline),
remainingDays: remaining,
overdue: remaining !== null && remaining < 0
} satisfies WorkbenchMyTaskItem;
});
}
export function filterWorkbenchMyTaskItems(items: readonly WorkbenchMyTaskItem[], bucket: WorkbenchMyTaskBucket) {
if (bucket === 'all') return [...items];
if (bucket === 'overdue') return items.filter(i => i.overdue);
if (bucket === 'today') return items.filter(i => i.remainingDays === 0);
return items.filter(i => i.remainingDays !== null && i.remainingDays >= 0 && i.remainingDays <= 7);
}
export interface WorkbenchMyRequirementGroupSource {
statusCode: string;
statusLabel: string;
count: number;
tone: 'sky' | 'amber' | 'emerald' | 'rose';
}
export type WorkbenchMyRequirementGroup = WorkbenchMyRequirementGroupSource;
export function buildWorkbenchMyRequirementGroups(
source: readonly WorkbenchMyRequirementGroupSource[]
): WorkbenchMyRequirementGroup[] {
return [...source];
}
export interface WorkbenchTeamTodoRowSource {
projectId: string;
projectName: string;
memberId: string;
memberName: string;
inProgress: number;
overdue: number;
weekDone: number;
}
export type WorkbenchTeamTodoRow = WorkbenchTeamTodoRowSource;
export function buildWorkbenchTeamTodoRows(source: readonly WorkbenchTeamTodoRowSource[]): WorkbenchTeamTodoRow[] {
return [...source].sort((a, b) => b.overdue - a.overdue);
}
export type WorkbenchHealthLevel = 'green' | 'yellow' | 'red';
export interface WorkbenchProjectHealthCardSource {
projectId: string;
projectName: string;
code: string;
health: WorkbenchHealthLevel;
riskCount: number;
overdueTasks: number;
backlogRequirements: number;
}
export interface WorkbenchProjectHealthCard extends WorkbenchProjectHealthCardSource {
healthLabel: string;
}
export function buildWorkbenchProjectHealthCards(
source: readonly WorkbenchProjectHealthCardSource[]
): WorkbenchProjectHealthCard[] {
const labelMap: Record<WorkbenchHealthLevel, string> = { green: '健康', yellow: '关注', red: '风险' };
return source.map(s => ({ ...s, healthLabel: labelMap[s.health] }));
}
export interface WorkbenchProgressBarSource {
projectId: string;
projectName: string;
/** 完成率 0-100 */
weekCompletionRate: number;
}
export type WorkbenchProgressBar = WorkbenchProgressBarSource;
export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBarSource[]): WorkbenchProgressBar[] {
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
}
export type WorkbenchFavoriteKind = 'product' | 'project' | 'requirement' | 'task';
export interface WorkbenchFavoriteItemSource {
id: string;
kind: WorkbenchFavoriteKind;
title: string;
source: string;
}
export interface WorkbenchFavoriteItem extends WorkbenchFavoriteItemSource {
kindLabel: string;
kindTone: 'sky' | 'emerald' | 'amber' | 'rose';
}
export function buildWorkbenchFavoriteItems(source: readonly WorkbenchFavoriteItemSource[]): WorkbenchFavoriteItem[] {
const meta: Record<WorkbenchFavoriteKind, { label: string; tone: 'sky' | 'emerald' | 'amber' | 'rose' }> = {
product: { label: '产品', tone: 'sky' },
project: { label: '项目', tone: 'emerald' },
requirement: { label: '需求', tone: 'amber' },
task: { label: '任务', tone: 'rose' }
};
return source.map(s => ({ ...s, kindLabel: meta[s.kind].label, kindTone: meta[s.kind].tone }));
}

View File

@@ -1,46 +1,151 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { ElMessageBox } from 'element-plus';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { buildWorkbenchBannerSummary } from './homepage';
import { workbenchBannerSummaryMock } from './mock';
import {
buildWorkbenchActivityItems,
buildWorkbenchBannerSummary,
buildWorkbenchKpiCards,
buildWorkbenchProjectItems,
buildWorkbenchTodoItems
} from './homepage';
import {
workbenchActivityMock,
workbenchBannerSummaryMock,
workbenchKpiMock,
workbenchProjectMock,
workbenchTodoMock
} from './mock';
type WorkbenchColumnId,
type WorkbenchModuleKey,
useWorkbenchModules
} from './composables/use-workbench-modules';
import WorkbenchBanner from './modules/workbench-banner.vue';
import WorkbenchColumn from './modules/workbench-column.vue';
import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
import WorkbenchKpi from './modules/workbench-kpi.vue';
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
import WorkbenchMyTask from './modules/workbench-my-task.vue';
import WorkbenchMyRequirement from './modules/workbench-my-requirement.vue';
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
import WorkbenchProgressChart from './modules/workbench-progress-chart.vue';
import WorkbenchFavorite from './modules/workbench-favorite.vue';
import WorkbenchShortcut from './modules/workbench-shortcut.vue';
defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules();
registerModuleComponent('kpi', WorkbenchKpi);
registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myProject', WorkbenchProjectGrid);
registerModuleComponent('activity', WorkbenchActivityPanel);
registerModuleComponent('myTask', WorkbenchMyTask);
registerModuleComponent('myRequirement', WorkbenchMyRequirement);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('progressChart', WorkbenchProgressChart);
registerModuleComponent('favorite', WorkbenchFavorite);
registerModuleComponent('shortcut', WorkbenchShortcut);
const workbench = useWorkbenchStore();
const libraryOpen = ref(false);
onMounted(() => {
workbench.load();
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (workbench.mode === 'editing' && workbench.dirty) {
e.preventDefault();
e.returnValue = '';
}
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload));
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload));
watch(
() => workbench.error,
err => {
if (err) window.$message?.error(`布局保存失败:${err.message}`);
}
);
const bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
const kpiCards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
const todoItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const activityItems = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
workbench.setColumnModules(columnId, modules);
}
async function handleReset() {
try {
await ElMessageBox.confirm('重置后将恢复默认布局,确认继续?', '重置默认布局', { type: 'warning' });
await workbench.resetToDefault();
} catch {
/* cancelled */
}
}
onBeforeRouteLeave(async (_to, _from, next) => {
if (workbench.mode === 'editing' && workbench.dirty) {
try {
await ElMessageBox.confirm('编辑布局未保存,确认离开?', '确认离开', { type: 'warning' });
workbench.cancelEditing();
next();
} catch {
next(false);
}
} else {
next();
}
});
</script>
<template>
<div class="workbench">
<WorkbenchBanner :summary="bannerSummary" />
<WorkbenchKpi :cards="kpiCards" />
<div class="workbench__toolbar">
<ElButton v-if="workbench.mode === 'normal'" type="primary" link @click="workbench.enterEditing">
<SvgIcon icon="mdi:pencil-outline" />
自定义布局
</ElButton>
<ElButton v-else type="primary" link @click="libraryOpen = true">
<SvgIcon icon="mdi:view-grid-plus-outline" />
模块库
</ElButton>
</div>
<section class="workbench__main">
<WorkbenchTodoPanel :items="todoItems" />
<WorkbenchActivityPanel :items="activityItems" />
<WorkbenchEditOverlay
v-if="workbench.mode === 'editing'"
:dirty="workbench.dirty"
:saving="workbench.saving"
@save="workbench.saveEditing"
@cancel="workbench.cancelEditing"
@reset="handleReset"
/>
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块">
<ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton>
</ElEmpty>
<section v-else class="workbench__main">
<WorkbenchColumn
v-for="col in workbench.layout.columns"
:key="col.id"
:column-id="col.id"
:modules="col.modules"
:editing="workbench.mode === 'editing'"
:collapsed="workbench.layout.collapsed"
@update:modules="onColumnUpdate(col.id, $event)"
@hide="workbench.hideModule"
@toggle-collapse="workbench.toggleCollapse"
/>
</section>
<WorkbenchProjectGrid :items="projectItems" />
<WorkbenchModuleLibrary
v-model="libraryOpen"
:hidden-metas="workbench.hiddenMetas"
@add-module="
(key, col) => {
workbench.showModule(key, col);
libraryOpen = false;
}
"
/>
</div>
</template>
@@ -50,13 +155,15 @@ const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectM
flex-direction: column;
gap: 16px;
}
.workbench__toolbar {
display: flex;
justify-content: flex-end;
}
.workbench__main {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 1280px) {
.workbench__main {
grid-template-columns: 1fr;

View File

@@ -2,8 +2,14 @@ import dayjs from 'dayjs';
import type {
WorkbenchActivityItemSource,
WorkbenchBannerSummarySource,
WorkbenchFavoriteItemSource,
WorkbenchKpiSource,
WorkbenchMyRequirementGroupSource,
WorkbenchMyTaskItemSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamTodoRowSource,
WorkbenchTodoItemSource
} from './homepage';
@@ -192,3 +198,145 @@ export const workbenchProjectMock = [
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
}
] satisfies WorkbenchProjectItemSource[];
export const workbenchMyTaskMock = [
{
id: 't-1',
title: '支付回调接口联调',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.add(1, 'day').hour(17))
},
{
id: 't-2',
title: '订单导出 V2 文档编写',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '订单中心 · 文档',
projectName: '订单中心',
priority: 'mid',
deadline: iso(now.add(3, 'day').hour(12))
},
{
id: 't-3',
title: 'API 返回结构调整',
statusCode: 'pending',
statusLabel: '待开始',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'mid',
deadline: iso(now.subtract(1, 'day').hour(18))
},
{
id: 't-4',
title: '会员等级文案校对',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '会员中心 · 文案',
projectName: '会员中心',
priority: 'low',
deadline: iso(now.add(2, 'day').hour(15))
},
{
id: 't-5',
title: '收银台 H5 适配',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 前端',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.hour(20))
}
] satisfies WorkbenchMyTaskItemSource[];
export const workbenchMyRequirementMock = [
{ statusCode: 'pendingReview', statusLabel: '待评审', count: 3, tone: 'amber' },
{ statusCode: 'reviewing', statusLabel: '评审中', count: 2, tone: 'sky' },
{ statusCode: 'developing', statusLabel: '开发中', count: 5, tone: 'emerald' },
{ statusCode: 'paused', statusLabel: '已暂停', count: 1, tone: 'rose' }
] satisfies WorkbenchMyRequirementGroupSource[];
export const workbenchTeamTodoMock = [
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-1',
memberName: '张三',
inProgress: 5,
overdue: 2,
weekDone: 3
},
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-2',
memberName: '李四',
inProgress: 3,
overdue: 0,
weekDone: 4
},
{
projectId: 'prj-2',
projectName: '会员中心',
memberId: 'm-3',
memberName: '王五',
inProgress: 2,
overdue: 1,
weekDone: 2
},
{
projectId: 'prj-3',
projectName: '订单中心',
memberId: 'm-4',
memberName: '赵六',
inProgress: 4,
overdue: 0,
weekDone: 5
}
] satisfies WorkbenchTeamTodoRowSource[];
export const workbenchProjectHealthMock = [
{
projectId: 'prj-1',
projectName: '收银台 V3',
code: 'CASHIER-V3',
health: 'yellow',
riskCount: 2,
overdueTasks: 3,
backlogRequirements: 2
},
{
projectId: 'prj-2',
projectName: '会员中心',
code: 'MEMBER',
health: 'green',
riskCount: 0,
overdueTasks: 0,
backlogRequirements: 1
},
{
projectId: 'prj-3',
projectName: '订单中心',
code: 'ORDER',
health: 'red',
riskCount: 4,
overdueTasks: 5,
backlogRequirements: 6
}
] satisfies WorkbenchProjectHealthCardSource[];
export const workbenchProgressChartMock = [
{ projectId: 'prj-1', projectName: '收银台 V3', weekCompletionRate: 78 },
{ projectId: 'prj-2', projectName: '会员中心', weekCompletionRate: 62 },
{ projectId: 'prj-3', projectName: '订单中心', weekCompletionRate: 45 }
] satisfies WorkbenchProgressBarSource[];
export const workbenchFavoriteMock = [
{ id: 'fav-1', kind: 'product', title: '收银台 V3', source: '产品库' },
{ id: 'fav-2', kind: 'project', title: '会员中心 · 一期', source: '项目库' },
{ id: 'fav-3', kind: 'requirement', title: '订单导出 V2', source: '收银台 V3' },
{ id: 'fav-4', kind: 'task', title: '支付回调接口联调', source: '收银台 V3 · 后端联调' }
] satisfies WorkbenchFavoriteItemSource[];

View File

@@ -1,24 +1,35 @@
<script setup lang="ts">
import type { WorkbenchActivityItem } from '../homepage';
import { computed } from 'vue';
import { buildWorkbenchActivityItems } from '../homepage';
import { workbenchActivityMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props {
items: WorkbenchActivityItem[];
editing?: boolean;
collapsed?: boolean;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const items = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
</script>
<template>
<ElCard class="workbench-activity card-wrapper" shadow="never">
<template #header>
<div>
<h3 class="workbench-activity__title">最近动态</h3>
<p class="workbench-activity__desc">关注与我相关的需求任务工单变化与 @ 提醒</p>
</div>
</template>
<WorkbenchModuleCard
title="最近动态"
icon="mdi:timeline-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div v-if="items.length" class="workbench-activity__list">
<article v-for="item in items" :key="item.id" class="workbench-activity__item">
<div class="workbench-activity__rail">
@@ -40,33 +51,10 @@ defineProps<Props>();
</article>
</div>
<ElEmpty v-else description="暂无动态" :image-size="72" />
</ElCard>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-activity {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-activity__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-activity__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-activity__list {
display: flex;
flex-direction: column;

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { getGreeting, getTodayLabel } from '../homepage';
import type { WorkbenchBannerSummary } from '../homepage';
@@ -13,7 +12,6 @@ interface Props {
const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学');
@@ -25,14 +23,6 @@ const rhythmItems = computed(() => [
{ label: '进行中', value: String(props.summary.weekInProgress), tone: 'sky' as const },
{ label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const }
]);
function handleCreateRequirement() {
routerPushByKey('product_list');
}
function handleCreateTask() {
routerPushByKey('project_list');
}
</script>
<template>
@@ -40,7 +30,6 @@ function handleCreateTask() {
<div class="workbench-banner__identity">
<div class="workbench-banner__title-group">
<h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1>
<span class="workbench-banner__decor-word">RDMS</span>
</div>
<p class="workbench-banner__subtitle">{{ todayLabel }}</p>
@@ -59,17 +48,6 @@ function handleCreateTask() {
<span class="workbench-banner__digest-unit"></span>
</div>
</div>
<div class="workbench-banner__actions">
<ElButton type="primary" @click="handleCreateRequirement">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建需求</span>
</ElButton>
<ElButton @click="handleCreateTask">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建任务</span>
</ElButton>
</div>
</div>
<div class="workbench-banner__rhythm">
@@ -131,18 +109,6 @@ function handleCreateTask() {
letter-spacing: -0.02em;
}
.workbench-banner__decor-word {
color: transparent;
background: linear-gradient(180deg, rgb(14 116 144 / 92%), rgb(13 148 136 / 60%));
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.32em;
text-shadow: 0 10px 24px rgb(14 116 144 / 14%);
user-select: none;
}
.workbench-banner__subtitle {
margin: 0;
color: rgb(100 116 139 / 92%);
@@ -191,17 +157,6 @@ function handleCreateTask() {
user-select: none;
}
.workbench-banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.workbench-banner__btn-icon {
margin-right: 4px;
font-size: 16px;
}
.workbench-banner__rhythm {
display: flex;
flex-direction: column;

View File

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

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
interface Props {
dirty: boolean;
saving: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
(e: 'save'): void;
(e: 'cancel'): void;
(e: 'reset'): void;
}>();
</script>
<template>
<div class="edit-overlay">
<span class="edit-overlay__hint">
<SvgIcon icon="mdi:cursor-move" />
正在编辑布局拖动模块换位 / 抽屉里把隐藏模块拖回来
</span>
<div class="edit-overlay__actions">
<ElButton @click="emit('reset')">重置默认布局</ElButton>
<ElButton @click="emit('cancel')">取消</ElButton>
<ElButton type="primary" :loading="saving" :disabled="!dirty && !saving" @click="emit('save')">
保存{{ dirty ? '*' : '' }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.edit-overlay {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 10px;
}
.edit-overlay__hint {
color: var(--el-color-primary);
display: inline-flex;
align-items: center;
gap: 8px;
}
.edit-overlay__actions {
display: inline-flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type WorkbenchFavoriteItem, buildWorkbenchFavoriteItems } from '../homepage';
import { workbenchFavoriteMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const items = computed(() => buildWorkbenchFavoriteItems(workbenchFavoriteMock));
function toneType(it: WorkbenchFavoriteItem): 'primary' | 'success' | 'warning' | 'danger' {
return ({ sky: 'primary', emerald: 'success', amber: 'warning', rose: 'danger' } as const)[it.kindTone];
}
</script>
<template>
<WorkbenchModuleCard
title="我的收藏"
icon="mdi:star-outline"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="info" :closable="false" class="favorite-hint">
v1 仅展示 mock业务页"收藏"入口将在 v2 加入
</ElAlert>
<ul class="favorite-list">
<li v-for="item in items" :key="item.id" class="favorite-item">
<ElTag size="small" :type="toneType(item)">{{ item.kindLabel }}</ElTag>
<span class="favorite-title">{{ item.title }}</span>
<span class="favorite-source">{{ item.source }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.favorite-hint {
margin-bottom: 10px;
}
.favorite-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.favorite-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.favorite-title {
font-weight: 500;
}
.favorite-source {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,13 +1,24 @@
<script setup lang="ts">
import type { WorkbenchKpiCard } from '../homepage';
import { computed } from 'vue';
import { type WorkbenchKpiCard, buildWorkbenchKpiCards } from '../homepage';
import { workbenchKpiMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchKpi' });
interface Props {
cards: WorkbenchKpiCard[];
editing?: boolean;
collapsed?: boolean;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const cards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
if (trend === 'up') return 'mdi:arrow-top-right-thin';
@@ -17,27 +28,36 @@ function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
</script>
<template>
<section class="workbench-kpi">
<article
v-for="card in cards"
:key="card.key"
class="workbench-kpi__card"
:class="`workbench-kpi__card--${card.tone}`"
>
<div class="workbench-kpi__card-header">
<span class="workbench-kpi__card-label">{{ card.label }}</span>
<span class="workbench-kpi__card-icon">
<SvgIcon :icon="card.icon" />
</span>
</div>
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
<span>{{ card.trendText }}</span>
</div>
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
</article>
</section>
<WorkbenchModuleCard
title="KPI 速览"
icon="mdi:view-dashboard-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<section class="workbench-kpi">
<article
v-for="card in cards"
:key="card.key"
class="workbench-kpi__card"
:class="`workbench-kpi__card--${card.tone}`"
>
<div class="workbench-kpi__card-header">
<span class="workbench-kpi__card-label">{{ card.label }}</span>
<span class="workbench-kpi__card-icon">
<SvgIcon :icon="card.icon" />
</span>
</div>
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
<span>{{ card.trendText }}</span>
</div>
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
</article>
</section>
</WorkbenchModuleCard>
</template>
<style scoped>

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' });
interface Props {
title: string;
icon?: string;
badgeCount?: number;
editing?: boolean;
collapsed?: boolean;
hasSettings?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
editing: false,
collapsed: false,
hasSettings: false
});
const emit = defineEmits<{
(e: 'toggle-collapse'): void;
(e: 'hide'): void;
(e: 'open-settings'): void;
(e: 'refresh'): void;
(e: 'navigate'): void;
}>();
const showBody = computed(() => !props.collapsed);
</script>
<template>
<section class="module-card" :class="{ 'is-editing': editing, 'is-collapsed': collapsed }">
<header class="module-card__head">
<span v-if="editing" class="module-drag-handle" title="拖动调整位置">
<SvgIcon icon="mdi:drag-vertical" />
</span>
<SvgIcon v-if="icon" class="module-card__icon" :icon="icon" />
<span class="module-card__title">{{ title }}</span>
<span v-if="badgeCount != null" class="module-card__badge">{{ badgeCount }}</span>
<div class="module-card__actions">
<ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')">
<SvgIcon icon="mdi:cog-outline" />
</ElButton>
<ElButton
v-if="!editing"
link
size="small"
:title="collapsed ? '展开' : '折叠'"
@click="emit('toggle-collapse')"
>
<SvgIcon :icon="collapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" />
</ElButton>
<ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')">
<SvgIcon icon="mdi:refresh" />
</ElButton>
<ElButton v-if="!editing" link size="small" title="跳详情" @click="emit('navigate')">
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
<ElButton v-if="editing" link size="small" title="隐藏此模块" type="danger" @click="emit('hide')">
<SvgIcon icon="mdi:close" />
</ElButton>
</div>
</header>
<div v-show="showBody" class="module-card__body">
<slot />
</div>
</section>
</template>
<style scoped>
.module-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
min-height: 180px;
display: flex;
flex-direction: column;
overflow: hidden;
transition:
border-color 120ms,
box-shadow 120ms;
}
.module-card.is-editing {
border-style: dashed;
border-color: var(--el-color-primary-light-5);
}
.module-card.is-collapsed {
min-height: auto;
}
.module-card__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
.module-card.is-collapsed .module-card__head {
border-bottom: none;
}
.module-drag-handle {
cursor: grab;
color: var(--el-text-color-secondary);
display: inline-flex;
align-items: center;
}
.module-drag-handle:active {
cursor: grabbing;
}
.module-card__icon {
color: var(--el-color-primary);
font-size: 16px;
}
.module-card__title {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.module-card__badge {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
}
.module-card__actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.module-card__body {
flex: 1;
padding: 14px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { WorkbenchColumnId, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
interface Props {
modelValue: boolean;
hiddenMetas: WorkbenchModuleMeta[];
}
defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
}>();
</script>
<template>
<ElDrawer
:model-value="modelValue"
direction="rtl"
size="320px"
title="模块库"
@update:model-value="emit('update:modelValue', $event)"
>
<template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p>
<ul class="library">
<li v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</li>
<li
v-for="meta in hiddenMetas"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
<ElTag size="small" :type="meta.category === 'manager' ? 'warning' : 'info'">
{{ meta.category === 'manager' ? '管理者' : meta.category === 'tool' ? '工具' : '个人' }}
</ElTag>
</li>
</ul>
</template>
</ElDrawer>
</template>
<style scoped>
.hint {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 0 0 12px;
}
.library {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.library-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
cursor: pointer;
transition: background 120ms;
}
.library-item:hover {
background: var(--el-color-primary-light-9);
}
.library-item__name {
flex: 1;
font-weight: 500;
}
.empty {
color: var(--el-text-color-placeholder);
text-align: center;
padding: 40px 0;
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchMyRequirementGroups } from '../homepage';
import { workbenchMyRequirementMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const { routerPushByKey } = useRouterPush();
const groups = computed(() => buildWorkbenchMyRequirementGroups(workbenchMyRequirementMock));
const total = computed(() => groups.value.reduce((s, g) => s + g.count, 0));
function handleClickGroup() {
routerPushByKey('product_list');
}
</script>
<template>
<WorkbenchModuleCard
title="我的需求"
icon="mdi:file-document-multiple-outline"
:badge-count="total"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@navigate="handleClickGroup"
>
<div class="req-grid">
<button
v-for="group in groups"
:key="group.statusCode"
class="req-card"
:class="`tone-${group.tone}`"
@click="handleClickGroup"
>
<span class="req-card__label">{{ group.statusLabel }}</span>
<span class="req-card__count">{{ group.count }}</span>
</button>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.req-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.req-card {
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
padding: 14px 16px;
border-radius: 8px;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 120ms;
}
.req-card:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px var(--el-color-primary-light-9);
}
.req-card__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.req-card__count {
font-size: 24px;
font-weight: 700;
}
.tone-sky .req-card__count {
color: #0284c7;
}
.tone-amber .req-card__count {
color: #d97706;
}
.tone-emerald .req-card__count {
color: #047857;
}
.tone-rose .req-card__count {
color: #be123c;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { type WorkbenchMyTaskBucket, buildWorkbenchMyTaskItems, filterWorkbenchMyTaskItems } from '../homepage';
import { workbenchMyTaskMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void; (e: 'refresh'): void }>();
const { routerPushByKey } = useRouterPush();
const allItems = computed(() => buildWorkbenchMyTaskItems(workbenchMyTaskMock));
const bucket = ref<WorkbenchMyTaskBucket>('today');
const visibleItems = computed(() => filterWorkbenchMyTaskItems(allItems.value, bucket.value));
function handleClickItem() {
routerPushByKey('project_list');
}
function handleRefresh() {
window.$message?.success('已刷新v1 mock');
}
</script>
<template>
<WorkbenchModuleCard
title="我的任务"
icon="mdi:checkbox-marked-circle-outline"
:badge-count="visibleItems.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@navigate="handleClickItem"
@refresh="handleRefresh"
>
<ElTabs v-model="bucket" class="my-task-tabs">
<ElTabPane label="今日" name="today" />
<ElTabPane label="本周" name="week" />
<ElTabPane label="逾期" name="overdue" />
<ElTabPane label="全部" name="all" />
</ElTabs>
<ul v-if="visibleItems.length" class="my-task-list">
<li v-for="item in visibleItems" :key="item.id" class="my-task-item" @click="handleClickItem">
<ElTag size="small" :type="item.overdue ? 'danger' : 'info'">{{ item.statusLabel }}</ElTag>
<span class="my-task-title">{{ item.title }}</span>
<span class="my-task-meta">{{ item.projectName }} · {{ item.executionName }}</span>
<span class="my-task-deadline" :class="{ overdue: item.overdue }">{{ item.deadlineLabel }}</span>
</li>
</ul>
<ElEmpty v-else description="暂无任务" :image-size="60" />
</WorkbenchModuleCard>
</template>
<style scoped>
.my-task-tabs {
margin-bottom: 4px;
}
.my-task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.my-task-item {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: 2px 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
background: var(--el-fill-color-lighter);
transition: background 120ms;
}
.my-task-item:hover {
background: var(--el-color-primary-light-9);
}
.my-task-title {
font-weight: 500;
}
.my-task-meta {
grid-column: 2 / 3;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.my-task-deadline {
grid-column: 3 / 4;
grid-row: 1 / 3;
align-self: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.my-task-deadline.overdue {
color: var(--el-color-danger);
font-weight: 600;
}
</style>

View File

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

View File

@@ -1,36 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import type { WorkbenchProjectItem } from '../homepage';
import { buildWorkbenchProjectItems } from '../homepage';
import { workbenchProjectMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props {
items: WorkbenchProjectItem[];
editing?: boolean;
collapsed?: boolean;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
const items = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
function handleEnterProjectList() {
routerPushByKey('project_list');
}
</script>
<template>
<ElCard class="workbench-project card-wrapper" shadow="never">
<template #header>
<div class="workbench-project__header">
<div>
<h3 class="workbench-project__title">我参与的项目</h3>
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
</div>
<ElButton type="primary" link @click="handleEnterProjectList">
<span>进入项目列表</span>
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
</template>
<WorkbenchModuleCard
title="我参与的项目"
icon="mdi:briefcase-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="workbench-project__subheader">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<ElButton type="primary" link @click="handleEnterProjectList">
<span>进入项目列表</span>
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
<div v-if="items.length" class="workbench-project__grid">
<article v-for="item in items" :key="item.id" class="workbench-project__card">
@@ -81,35 +94,20 @@ function handleEnterProjectList() {
</article>
</div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</ElCard>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-project {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-project__header {
.workbench-project__subheader {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.workbench-project__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.workbench-project__desc {
margin: 4px 0 0;
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchProjectHealthCards } from '../homepage';
import { workbenchProjectHealthMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
</script>
<template>
<WorkbenchModuleCard
title="项目健康度"
icon="mdi:heart-pulse"
:badge-count="cards.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="health-list">
<div v-for="card in cards" :key="card.projectId" class="health-card">
<div class="health-card__ring" :class="`is-${card.health}`">
<span>{{ card.healthLabel }}</span>
</div>
<div class="health-card__body">
<div class="health-card__name">{{ card.projectName }}</div>
<div class="health-card__meta">
<ElTag v-if="card.riskCount > 0" size="small" type="danger">风险 {{ card.riskCount }}</ElTag>
<ElTag size="small" type="warning">逾期任务 {{ card.overdueTasks }}</ElTag>
<ElTag size="small">需求积压 {{ card.backlogRequirements }}</ElTag>
</div>
</div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.health-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.health-card {
display: flex;
align-items: center;
gap: 14px;
padding: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.health-card__ring {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.health-card__ring.is-green {
background: #10b981;
}
.health-card__ring.is-yellow {
background: #f59e0b;
}
.health-card__ring.is-red {
background: #ef4444;
}
.health-card__name {
font-weight: 600;
margin-bottom: 6px;
}
.health-card__meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElTree } from 'element-plus';
import { useRouteStore } from '@/store/modules/route';
interface Props {
modelValue: boolean;
/** 已选菜单 key */
initialSelected: string[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'confirm', keys: string[]): void;
}>();
const routeStore = useRouteStore();
interface TreeNode {
key: string;
label: string;
children?: TreeNode[];
isLeaf: boolean;
}
function toTreeNodes(menus: any[]): TreeNode[] {
return menus.map(m => ({
key: m.key,
label: m.label as string,
isLeaf: !m.children || m.children.length === 0,
children: m.children ? toTreeNodes(m.children) : undefined
}));
}
const treeData = computed(() => toTreeNodes(routeStore.menus));
const treeRef = ref<InstanceType<typeof ElTree> | null>(null);
const checkedKeys = ref<string[]>([...props.initialSelected]);
watch(
() => props.modelValue,
open => {
if (open) {
checkedKeys.value = [...props.initialSelected];
// 等抽屉 transition 后设置 checked
setTimeout(() => treeRef.value?.setCheckedKeys(props.initialSelected, true), 100);
}
}
);
function handleConfirm() {
const allChecked = treeRef.value?.getCheckedKeys(true) ?? []; // leafOnly = true
emit(
'confirm',
allChecked.map(k => String(k))
);
emit('update:modelValue', false);
}
</script>
<template>
<ElDrawer
:model-value="modelValue"
direction="rtl"
size="380px"
title="选择快捷入口菜单"
@update:model-value="emit('update:modelValue', $event)"
>
<template #default>
<p class="hint">勾选你想加入快捷入口的菜单仅叶子节点可勾选</p>
<ElTree
ref="treeRef"
:data="treeData"
node-key="key"
:props="{ label: 'label', children: 'children' }"
show-checkbox
check-strictly
default-expand-all
:check-on-click-node="false"
/>
</template>
<template #footer>
<div class="footer">
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">保存</ElButton>
</div>
</template>
</ElDrawer>
</template>
<style scoped>
.hint {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 0 0 12px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouteStore } from '@/store/modules/route';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { useRouterPush } from '@/hooks/common/router';
import WorkbenchModuleCard from './workbench-module-card.vue';
import WorkbenchShortcutPicker from './workbench-shortcut-picker.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), {
editing: false,
collapsed: false
});
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const routeStore = useRouteStore();
const workbench = useWorkbenchStore();
const { routerPushByKey } = useRouterPush();
interface FlatMenu {
key: string;
label: string;
icon?: string;
}
function flatten(menus: typeof routeStore.menus): FlatMenu[] {
const out: FlatMenu[] = [];
function walk(list: typeof menus) {
list.forEach((m: any) => {
if (m.children && m.children.length) {
walk(m.children);
} else {
out.push({
key: m.key,
label: m.label as string,
icon: m.i18nKey || m.icon || ''
});
}
});
}
walk(menus);
return out;
}
const flatMenus = computed(() => flatten(routeStore.menus));
const selectedKeys = computed(() => workbench.layout.settings.shortcut?.menuKeys ?? []);
const selected = computed(() =>
selectedKeys.value.map(k => flatMenus.value.find(m => m.key === k)).filter((x): x is FlatMenu => Boolean(x))
);
const pickerOpen = ref(false);
function openPicker() {
pickerOpen.value = true;
}
function handleClick(key: string) {
routerPushByKey(key as any);
}
function handleConfirm(keys: string[]) {
workbench.updateModuleSettings('shortcut', { menuKeys: keys });
}
</script>
<template>
<WorkbenchModuleCard
title="快捷入口"
icon="mdi:rocket-launch-outline"
:badge-count="selected.length || undefined"
:editing="editing"
:collapsed="collapsed"
has-settings
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@open-settings="openPicker"
>
<div v-if="selected.length === 0" class="shortcut-empty">
<ElEmpty description="还未选择菜单" :image-size="60">
<ElButton type="primary" size="small" @click="openPicker">+ 选择菜单</ElButton>
</ElEmpty>
</div>
<div v-else class="shortcut-grid">
<button v-for="item in selected" :key="item.key" class="shortcut-item" @click="handleClick(item.key)">
<SvgIcon icon="mdi:link-variant" />
<span>{{ item.label }}</span>
</button>
</div>
<WorkbenchShortcutPicker v-model="pickerOpen" :initial-selected="selectedKeys" @confirm="handleConfirm" />
</WorkbenchModuleCard>
</template>
<style scoped>
.shortcut-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.shortcut-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: var(--el-text-color-primary);
transition: all 120ms;
}
.shortcut-item:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.shortcut-empty {
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchTeamTodoRows } from '../homepage';
import { workbenchTeamTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const rows = computed(() => buildWorkbenchTeamTodoRows(workbenchTeamTodoMock));
</script>
<template>
<WorkbenchModuleCard
title="团队待办汇总"
icon="mdi:account-group-outline"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElTable :data="rows" stripe size="small">
<ElTableColumn prop="projectName" label="项目" min-width="120" />
<ElTableColumn prop="memberName" label="成员" width="80" />
<ElTableColumn prop="inProgress" label="进行中" width="80" align="right" />
<ElTableColumn label="逾期" width="80" align="right">
<template #default="{ row }">
<span
:style="{
color: row.overdue > 0 ? 'var(--el-color-danger)' : 'inherit',
fontWeight: row.overdue > 0 ? 600 : 'normal'
}"
>
{{ row.overdue }}
</span>
</template>
</ElTableColumn>
<ElTableColumn prop="weekDone" label="本周完成" width="100" align="right" />
</ElTable>
</WorkbenchModuleCard>
</template>

View File

@@ -2,16 +2,28 @@
import { computed, ref } from 'vue';
import type { RouteKey } from '@elegant-router/types';
import { useRouterPush } from '@/hooks/common/router';
import { filterWorkbenchTodoItems } from '../homepage';
import type { WorkbenchTodoItem, WorkbenchTodoTimeBucket } from '../homepage';
import {
type WorkbenchTodoItem,
type WorkbenchTodoTimeBucket,
buildWorkbenchTodoItems,
filterWorkbenchTodoItems
} from '../homepage';
import { workbenchTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
items: WorkbenchTodoItem[];
editing?: boolean;
collapsed?: boolean;
}
const props = defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
@@ -24,14 +36,16 @@ const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
{ key: 'overdue', label: '逾期' }
];
const items = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const bucketCounts = computed(() => ({
all: props.items.length,
today: filterWorkbenchTodoItems(props.items, 'today').length,
week: filterWorkbenchTodoItems(props.items, 'week').length,
overdue: filterWorkbenchTodoItems(props.items, 'overdue').length
all: items.value.length,
today: filterWorkbenchTodoItems(items.value, 'today').length,
week: filterWorkbenchTodoItems(items.value, 'week').length,
overdue: filterWorkbenchTodoItems(items.value, 'overdue').length
}));
const filteredItems = computed(() => filterWorkbenchTodoItems(props.items, activeBucket.value));
const filteredItems = computed(() => filterWorkbenchTodoItems(items.value, activeBucket.value));
function handleClickItem(item: WorkbenchTodoItem) {
if (!item.routeKey) return;
@@ -48,28 +62,27 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</script>
<template>
<ElCard class="workbench-todo card-wrapper" shadow="never">
<template #header>
<div class="workbench-todo__header">
<div class="workbench-todo__title-group">
<h3 class="workbench-todo__title">我的待办</h3>
<p class="workbench-todo__desc">需要我处理的需求评审任务工单与 @ 提醒</p>
</div>
<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>
</button>
</div>
</div>
</template>
<WorkbenchModuleCard
title="我的待办"
icon="mdi:clipboard-text-clock-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@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>
</button>
</div>
<div v-if="filteredItems.length" class="workbench-todo__list">
<article
@@ -101,49 +114,15 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</article>
</div>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
</ElCard>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-todo {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-todo__header {
display: flex;
flex-direction: column;
gap: 14px;
}
.workbench-todo__title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-todo__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-todo__desc {
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-todo__tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.workbench-todo__tab {