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

@@ -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 {