refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题
This commit is contained in:
@@ -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