refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题
This commit is contained in:
28
src/views/workbench/composables/layout-storage-local.ts
Normal file
28
src/views/workbench/composables/layout-storage-local.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
6
src/views/workbench/composables/layout-storage.ts
Normal file
6
src/views/workbench/composables/layout-storage.ts
Normal 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>;
|
||||
}
|
||||
158
src/views/workbench/composables/use-workbench-layout.ts
Normal file
158
src/views/workbench/composables/use-workbench-layout.ts
Normal 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
|
||||
};
|
||||
}
|
||||
160
src/views/workbench/composables/use-workbench-modules.ts
Normal file
160
src/views/workbench/composables/use-workbench-modules.ts
Normal 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 };
|
||||
}
|
||||
30
src/views/workbench/composables/workbench-layout-default.ts
Normal file
30
src/views/workbench/composables/workbench-layout-default.ts
Normal 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: {}
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
22
src/views/workbench/composables/workbench-layout-types.ts
Normal file
22
src/views/workbench/composables/workbench-layout-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
src/views/workbench/modules/workbench-column.vue
Normal file
61
src/views/workbench/modules/workbench-column.vue
Normal 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>
|
||||
53
src/views/workbench/modules/workbench-edit-overlay.vue
Normal file
53
src/views/workbench/modules/workbench-edit-overlay.vue
Normal 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>
|
||||
72
src/views/workbench/modules/workbench-favorite.vue
Normal file
72
src/views/workbench/modules/workbench-favorite.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
150
src/views/workbench/modules/workbench-module-card.vue
Normal file
150
src/views/workbench/modules/workbench-module-card.vue
Normal 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>
|
||||
80
src/views/workbench/modules/workbench-module-library.vue
Normal file
80
src/views/workbench/modules/workbench-module-library.vue
Normal 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>
|
||||
101
src/views/workbench/modules/workbench-my-requirement.vue
Normal file
101
src/views/workbench/modules/workbench-my-requirement.vue
Normal 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>
|
||||
105
src/views/workbench/modules/workbench-my-task.vue
Normal file
105
src/views/workbench/modules/workbench-my-task.vue
Normal 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>
|
||||
78
src/views/workbench/modules/workbench-progress-chart.vue
Normal file
78
src/views/workbench/modules/workbench-progress-chart.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
97
src/views/workbench/modules/workbench-project-health.vue
Normal file
97
src/views/workbench/modules/workbench-project-health.vue
Normal 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>
|
||||
102
src/views/workbench/modules/workbench-shortcut-picker.vue
Normal file
102
src/views/workbench/modules/workbench-shortcut-picker.vue
Normal 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>
|
||||
132
src/views/workbench/modules/workbench-shortcut.vue
Normal file
132
src/views/workbench/modules/workbench-shortcut.vue
Normal 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>
|
||||
47
src/views/workbench/modules/workbench-team-todo.vue
Normal file
47
src/views/workbench/modules/workbench-team-todo.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user