feat(projects): 工作台部分组件调成真实数据

This commit is contained in:
2026-06-04 11:26:51 +08:00
parent acef4418d8
commit 39458386ae
33 changed files with 1033 additions and 1169 deletions

View File

@@ -1,61 +0,0 @@
<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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { inject } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' });
@@ -12,31 +12,25 @@ interface Props {
icon?: string;
badgeCount?: number;
editing?: boolean;
collapsed?: boolean;
hasSettings?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
icon: undefined,
badgeCount: undefined,
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 }">
<section class="module-card" :class="{ 'is-editing': editing }">
<header class="module-card__head">
<span v-if="editing" class="module-drag-handle" title="拖动调整位置">
<SvgIcon icon="mdi:drag-vertical" />
@@ -49,21 +43,9 @@ const showBody = computed(() => !props.collapsed);
<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 && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()">
<SvgIcon icon="mdi:view-dashboard-edit-outline" />
</ElButton>
@@ -73,7 +55,7 @@ const showBody = computed(() => !props.collapsed);
</div>
</header>
<div v-show="showBody" class="module-card__body">
<div class="module-card__body">
<slot />
</div>
</section>
@@ -84,7 +66,7 @@ const showBody = computed(() => !props.collapsed);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
min-height: 180px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -98,10 +80,6 @@ const showBody = computed(() => !props.collapsed);
border-color: var(--el-color-primary-light-5);
}
.module-card.is-collapsed {
min-height: auto;
}
.module-card__head {
display: flex;
align-items: center;
@@ -111,10 +89,6 @@ const showBody = computed(() => !props.collapsed);
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);
@@ -153,7 +127,10 @@ const showBody = computed(() => !props.collapsed);
.module-card__body {
flex: 1;
min-height: 0;
padding: 14px;
overflow: auto;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
import type { WorkbenchModuleCategory, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
interface Props {
modelValue: boolean;
@@ -13,7 +9,7 @@ interface Props {
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
(e: 'add-module', key: WorkbenchModuleMeta['key']): void;
}>();
// 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具
@@ -57,18 +53,13 @@ const groups = computed<Array<{ key: LibraryGroupKey; label: string; items: Work
@update:model-value="emit('update:modelValue', $event)"
>
<template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p>
<p class="hint">点击下方模块加入工作台落到网格底部可在编辑态拖动调整</p>
<div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div>
<div v-else class="library">
<section v-for="group in groups" :key="group.key" class="library-group">
<h4 class="library-group__title">{{ group.label }}</h4>
<ul class="library-group__list">
<li
v-for="meta in group.items"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<li v-for="meta in group.items" :key="meta.key" class="library-item" @click="emit('add-module', meta.key)">
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
</li>

View File

@@ -1,30 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetMyExecutionPage } from '@/service/api';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared';
import { type WorkbenchMyExecutionItem, buildWorkbenchMyExecutionItems } from '../homepage';
import { workbenchMyExecutionMock } from '../mock';
import { buildWorkbenchMyExecutionItems } from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' });
type MyExecutionItem = Api.Project.MyExecutionItem;
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const router = useRouter();
const items = computed(() => buildWorkbenchMyExecutionItems(workbenchMyExecutionMock));
const items = ref<MyExecutionItem[]>([]);
const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉取全部当前用户负责的进行中执行;状态/进度过滤由后端完成
const { data, error } = await fetchGetMyExecutionPage({ pageNo: 1, pageSize: -1 });
if (error) return;
items.value = buildWorkbenchMyExecutionItems(data?.list ?? []);
});
onMounted(refresh);
// 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前)
const groups = computed<Array<{ projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>>(() => {
const map = new Map<string, { projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>();
const groups = computed<Array<{ projectId: string; projectName: string; items: MyExecutionItem[] }>>(() => {
const map = new Map<string, { projectId: string; projectName: string; items: MyExecutionItem[] }>();
items.value.forEach(item => {
if (!map.has(item.projectId)) {
map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] });
@@ -42,6 +53,26 @@ const groups = computed<Array<{ projectId: string; projectName: string; items: W
return groupsArr.sort((a, b) => b.items.length - a.items.length);
});
// 手风琴:单开,默认展开第一个项目(执行最多);展开项消失时回退到第一个
const expandedProjectId = ref<string>('');
watch(
groups,
list => {
if (!list.length) {
expandedProjectId.value = '';
return;
}
if (!list.some(g => g.projectId === expandedProjectId.value)) {
expandedProjectId.value = list[0].projectId;
}
},
{ immediate: true }
);
function toggleProject(projectId: string) {
expandedProjectId.value = expandedProjectId.value === projectId ? '' : projectId;
}
function goProjectExecutionPool(projectId: string) {
router.push({
path: '/project/project/execution',
@@ -49,7 +80,7 @@ function goProjectExecutionPool(projectId: string) {
});
}
function goRequirementDetail(item: WorkbenchMyExecutionItem) {
function goRequirementDetail(item: MyExecutionItem) {
if (!item.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
@@ -67,27 +98,43 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
icon="mdi:flag-checkered"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div v-if="items.length" class="exec-groups">
<section v-for="group in groups" :key="group.projectId" class="exec-group">
<header class="exec-group__head">
<div v-if="items.length" v-loading="loading" class="exec-groups">
<section
v-for="group in groups"
:key="group.projectId"
class="exec-group"
:class="{ 'is-open': expandedProjectId === group.projectId }"
>
<header
class="exec-group__head"
role="button"
tabindex="0"
:aria-expanded="expandedProjectId === group.projectId"
@click="toggleProject(group.projectId)"
@keydown.enter.prevent="toggleProject(group.projectId)"
>
<SvgIcon
icon="mdi:chevron-right"
class="exec-group__chevron"
:class="{ 'is-open': expandedProjectId === group.projectId }"
/>
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
<span
class="exec-group__name"
role="button"
tabindex="0"
:title="`进入「${group.projectName}」执行池`"
@click="goProjectExecutionPool(group.projectId)"
@keydown.enter.prevent="goProjectExecutionPool(group.projectId)"
>
{{ group.projectName }}
</span>
<span class="exec-group__name" :title="group.projectName">{{ group.projectName }}</span>
<span class="exec-group__count">{{ group.items.length }}</span>
<ElButton
link
size="small"
class="exec-group__go"
:title="`进入「${group.projectName}」执行池`"
@click.stop="goProjectExecutionPool(group.projectId)"
>
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
</header>
<ul class="exec-list">
<ul v-show="expandedProjectId === group.projectId" class="exec-list">
<li v-for="item in group.items" :key="item.id" class="exec-item">
<div class="exec-head">
<span class="exec-name" :title="item.executionName">{{ item.executionName }}</span>
@@ -122,6 +169,7 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</div>
<div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row">
<SvgIcon icon="mdi:link-variant" class="exec-meta__icon" />
<span class="exec-meta__label">需求</span>
<span
class="exec-meta__text exec-meta__link"
role="button"
@@ -139,28 +187,66 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
</ul>
</section>
</div>
<ElEmpty v-else description="暂无进行中的执行" :image-size="60" />
<div v-else v-loading="loading" class="exec-empty">
<ElEmpty description="暂无进行中的执行" :image-size="60" />
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.exec-groups {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
gap: 6px;
}
.exec-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.exec-group__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 0 2px 6px;
border-bottom: 1px dashed var(--el-border-color-lighter);
padding: 8px 10px;
border-radius: 6px;
/* 常驻分组底色,明显区别于下方执行项卡片 */
background: var(--el-fill-color-light);
border-left: 3px solid transparent;
cursor: pointer;
user-select: none;
transition:
background 0.16s ease,
border-color 0.16s ease;
}
.exec-group__head:hover,
.exec-group__head:focus-visible {
background: var(--el-fill-color);
outline: none;
}
.exec-group.is-open > .exec-group__head {
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.exec-group__chevron {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
transition: transform 0.18s ease;
}
.exec-group__chevron.is-open {
transform: rotate(90deg);
}
.exec-group__icon {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
font-size: 15px;
/* 项目 icon 用主色,与项目管理业务域图标一致且更醒目 */
color: var(--el-color-primary);
}
.exec-group__name {
flex: 1;
@@ -168,17 +254,16 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
font-size: 13.5px;
font-weight: 600;
color: var(--el-text-color-regular);
color: var(--el-text-color-primary);
letter-spacing: 0.01em;
cursor: pointer;
transition: color 0.16s ease;
}
.exec-group__name:hover,
.exec-group__name:focus-visible {
.exec-group.is-open .exec-group__name {
color: var(--el-color-primary);
outline: none;
}
.exec-group__go {
flex-shrink: 0;
}
.exec-group__count {
flex-shrink: 0;
@@ -195,8 +280,8 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
}
.exec-list {
list-style: none;
margin: 0;
padding: 0;
margin: 4px 0 2px;
padding: 0 2px 0 22px;
display: flex;
flex-direction: column;
gap: 8px;
@@ -252,6 +337,10 @@ function goRequirementDetail(item: WorkbenchMyExecutionItem) {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.exec-meta__label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.exec-meta__text {
overflow: hidden;
white-space: nowrap;

View File

@@ -13,19 +13,21 @@ import {
buildWorkbenchWeekWorklogView
} from '../homepage';
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const router = useRouter();
const { loading, refresh } = useWorkbenchRefresh();
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) {
@@ -302,12 +304,12 @@ watch(activeTab, async tab => {
<template>
<WorkbenchModuleCard
v-loading="loading"
title="工时"
icon="mdi:timer-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="ww-tabbar">
<ElTabs v-model="activeTab" class="ww-tabs">
@@ -327,7 +329,7 @@ watch(activeTab, async tab => {
</div>
<!-- ============ 我的工时 tab ============ -->
<div v-show="activeTab === 'my'">
<div v-show="activeTab === 'my'" class="ww-tab-content">
<template v-if="myView">
<div class="ww-headline">
<div class="ww-section-title">
@@ -375,7 +377,7 @@ watch(activeTab, async tab => {
</div>
<!-- ============ 团队工时 tab ============ -->
<div v-show="activeTab === 'team'">
<div v-show="activeTab === 'team'" class="ww-tab-content">
<template v-if="teamView">
<div class="tw-kpis">
<div class="tw-kpi">
@@ -444,6 +446,19 @@ watch(activeTab, async tab => {
gap: 12px;
margin-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
/* tab 内容区填充剩余高度flex 列布局,图表区自适应撑满,不写死高度、不内部滚动 */
.ww-tab-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ww-tab-content :deep(.el-empty) {
margin: auto;
}
.ww-tabs {
flex: 1;
@@ -468,12 +483,15 @@ watch(activeTab, async tab => {
align-items: center;
gap: 16px;
margin-bottom: 10px;
flex-shrink: 0;
}
.ww-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
}
@media (width <= 520px) {
.ww-grid {
@@ -488,6 +506,7 @@ watch(activeTab, async tab => {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.ww-section-title {
@@ -508,7 +527,8 @@ watch(activeTab, async tab => {
.ww-pie-wrap {
position: relative;
width: 100%;
height: 280px;
flex: 1;
min-height: 0;
}
.ww-pie {
width: 100%;
@@ -517,7 +537,8 @@ watch(activeTab, async tab => {
.ww-bar {
width: 100%;
height: 280px;
flex: 1;
min-height: 0;
}
.ww-bar-legend {
display: flex;
@@ -526,6 +547,7 @@ watch(activeTab, async tab => {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.ww-bar-legend__item {
display: inline-flex;
@@ -547,6 +569,7 @@ watch(activeTab, async tab => {
padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 13px;
flex-shrink: 0;
}
.ww-footer b {
font-weight: 700;
@@ -570,6 +593,7 @@ watch(activeTab, async tab => {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
flex-shrink: 0;
}
.tw-kpi {
display: flex;
@@ -609,7 +633,8 @@ watch(activeTab, async tab => {
.tw-bar {
width: 100%;
height: 240px;
flex: 1;
min-height: 0;
}
.tw-footer {
@@ -619,6 +644,7 @@ watch(activeTab, async tab => {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.tw-footer b {
color: var(--el-text-color-primary);

View File

@@ -1,293 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchNoticeNotification' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface NoticeRow {
id: string;
title: string;
timeLabel: string;
}
interface NotificationRow {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const notices: NoticeRow[] = [
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' }
];
const notifications: NotificationRow[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前', unread: true },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前', unread: true },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日', unread: false }
];
const unreadCount = computed(() => notifications.filter(n => n.unread).length);
// mock 阶段:交互函数留占位,等后端接口落地后接通
function handleOpenNotification(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 跳对应业务对象详情
// eslint-disable-next-line no-console
console.warn('[notification] open', row.id);
}
function handleMarkRead(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 调标已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', row.id);
}
function handleMarkAllRead() {
// eslint-disable-next-line no-warning-comments
// TODO: 调一键全部已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
</script>
<template>
<WorkbenchModuleCard
title="公告与通知"
icon="mdi:bullhorn-outline"
:badge-count="unreadCount || undefined"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<!-- 1/3公告只读露头扫一眼 -->
<section class="nn-col nn-col--notice">
<header class="nn-h">
<SvgIcon icon="mdi:bullhorn-outline" class="nn-h__icon" />
<span class="nn-h__title">公告</span>
<span class="nn-h__count">{{ notices.length }}</span>
</header>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id" class="nn-notice">
<div class="nn-notice__title">{{ row.title }}</div>
<div class="nn-notice__time">{{ row.timeLabel }}</div>
</li>
</ul>
</section>
<!-- 2/3通知可操作按行跳详情/标已读 -->
<section class="nn-col nn-col--notify">
<header class="nn-h">
<SvgIcon icon="mdi:bell-outline" class="nn-h__icon" />
<span class="nn-h__title">通知</span>
<span v-if="unreadCount > 0" class="nn-h__count is-unread">未读 {{ unreadCount }}</span>
<span v-else class="nn-h__count">{{ notifications.length }}</span>
<ElButton v-if="unreadCount > 0" link size="small" class="nn-h__action" @click="handleMarkAllRead">
全部已读
</ElButton>
</header>
<ul class="nn-list">
<li
v-for="row in notifications"
:key="row.id"
class="nn-notify"
:class="{ 'is-unread': row.unread }"
@click="handleOpenNotification(row)"
>
<span v-if="row.unread" class="nn-notify__dot" />
<span class="nn-notify__title">{{ row.title }}</span>
<span class="nn-notify__time">{{ row.timeLabel }}</span>
<span class="nn-notify__actions">
<ElTooltip v-if="row.unread" content="标为已读" placement="top">
<button class="nn-notify__act" @click.stop="handleMarkRead(row)">
<SvgIcon icon="mdi:check" />
</button>
</ElTooltip>
<ElTooltip content="跳详情" placement="top">
<button class="nn-notify__act" @click.stop="handleOpenNotification(row)">
<SvgIcon icon="mdi:open-in-new" />
</button>
</ElTooltip>
</span>
</li>
</ul>
</section>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
.nn-col {
min-width: 0;
}
.nn-h {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
}
.nn-h__icon {
color: var(--el-color-primary);
font-size: 14px;
}
.nn-h__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.nn-h__count {
padding: 1px 7px;
border-radius: 999px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 1.5;
}
.nn-h__count.is-unread {
background: var(--el-color-danger);
color: #fff;
font-weight: 600;
}
.nn-h__action {
margin-left: auto;
font-size: 11px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.nn-list::-webkit-scrollbar {
width: 6px;
}
.nn-list::-webkit-scrollbar-thumb {
background: var(--el-fill-color-darker);
border-radius: 3px;
}
.nn-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
/* 公告行:纯阅读 + 标题 2 行 clamp */
.nn-notice {
padding: 7px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.nn-notice:last-child {
border-bottom: none;
}
.nn-notice__title {
font-size: 12.5px;
line-height: 1.5;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.nn-notice__time {
margin-top: 3px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 通知行:可操作 + hover 浮出动作按钮 */
.nn-notify {
display: grid;
grid-template-columns: 8px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 8px;
margin: 0 -8px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 120ms;
}
.nn-notify + .nn-notify {
border-top: 1px dashed var(--el-border-color-lighter);
}
.nn-notify:hover {
background: var(--el-fill-color-light);
}
.nn-notify__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary);
}
.nn-notify:not(.is-unread) .nn-notify__dot {
background: transparent;
}
.nn-notify__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--el-text-color-regular);
}
.nn-notify.is-unread .nn-notify__title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.nn-notify__time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
white-space: nowrap;
}
.nn-notify__actions {
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 120ms;
}
.nn-notify:hover .nn-notify__actions {
opacity: 1;
}
.nn-notify__act {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 13px;
transition: background-color 120ms;
}
.nn-notify__act:hover {
background: var(--el-fill-color);
color: var(--el-color-primary);
}
</style>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProductSnapshot' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
interface ProductOption {
id: string;
@@ -69,12 +71,12 @@ function onChange(id: string) {
<template>
<WorkbenchModuleCard
v-loading="loading"
title="产品深度快照"
icon="mdi:image-area-close"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="ps-head">
<span class="ps-pin-label">当前产品</span>

View File

@@ -1,22 +1,26 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { fetchGetMyOwnedProjectPage, fetchGetMyParticipatedProjectPage } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchOwnedProjectItems, buildWorkbenchProjectItems } from '../homepage';
import { workbenchOwnedProjectMock, workbenchProjectMock } from '../mock';
import {
type WorkbenchOwnedProjectView,
type WorkbenchParticipatedProjectView,
buildWorkbenchOwnedProjects,
buildWorkbenchParticipatedProjects
} from '../homepage';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
@@ -25,10 +29,26 @@ type ProjectViewKey = 'participated' | 'owned';
const activeView = ref<ProjectViewKey>('participated');
const participatedItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
const ownedItems = computed(() => buildWorkbenchOwnedProjectItems(workbenchOwnedProjectMock));
const participatedItems = ref<WorkbenchParticipatedProjectView[]>([]);
const ownedItems = ref<WorkbenchOwnedProjectView[]>([]);
const currentOwnedId = ref<string>(ownedItems.value[0]?.id ?? '');
const { loading, refresh } = useWorkbenchRefresh(async () => {
// pageSize=-1 一次拉全部;列表已由后端按"进行中 + 创建时间升序"过滤排序
const [participated, owned] = await Promise.all([
fetchGetMyParticipatedProjectPage({ pageNo: 1, pageSize: -1 }),
fetchGetMyOwnedProjectPage({ pageNo: 1, pageSize: -1 })
]);
if (!participated.error) {
participatedItems.value = buildWorkbenchParticipatedProjects(participated.data?.list ?? []);
}
if (!owned.error) {
ownedItems.value = buildWorkbenchOwnedProjects(owned.data?.list ?? []);
}
});
onMounted(refresh);
const currentOwnedId = ref<string>('');
watch(ownedItems, list => {
if (!list.find(item => item.id === currentOwnedId.value)) {
currentOwnedId.value = list[0]?.id ?? '';
@@ -36,6 +56,24 @@ watch(ownedItems, list => {
});
const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null);
// 成员负载:柱长按组内最高任务数归一(相对负载),颜色按绝对任务数分档(与团队负载 6/4 阈值一致)
function resolveMemberLoadLevel(activeTaskCount: number) {
if (activeTaskCount >= 6) return 'over';
if (activeTaskCount >= 4) return 'warn';
return 'ok';
}
const ownedMembersView = computed(() => {
const members = currentOwned.value?.members ?? [];
const maxTaskCount = members.reduce((max, member) => Math.max(max, member.activeTaskCount), 0);
return members.map(member => ({
userId: member.userId,
userName: member.userName,
activeTaskCount: member.activeTaskCount,
barPercent: maxTaskCount > 0 ? Math.round((member.activeTaskCount / maxTaskCount) * 100) : 0,
level: resolveMemberLoadLevel(member.activeTaskCount)
}));
});
function handleEnterProjectList() {
routerPushByKey('project_list');
}
@@ -43,12 +81,12 @@ function handleEnterProjectList() {
<template>
<WorkbenchModuleCard
v-loading="loading"
title="我的项目"
icon="mdi:briefcase-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="workbench-project__tabs">
<ElRadioGroup v-model="activeView" size="small">
@@ -60,122 +98,125 @@ function handleEnterProjectList() {
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
<div class="workbench-project__scroll">
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<div v-if="participatedItems.length" class="workbench-project__grid">
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title">{{ item.name }}</h4>
<span class="workbench-project__card-code">{{ item.code }}</span>
<div v-if="participatedItems.length" class="workbench-project__grid">
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title" :title="item.name">{{ item.name }}</h4>
<span v-if="item.code" class="workbench-project__card-code">{{ item.code }}</span>
</div>
<span
class="workbench-project__card-status"
:class="`workbench-project__card-status--${item.statusTone}`"
>
{{ item.statusName || '进行中' }}
</span>
</div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
</div>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong>
</div>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole || '—' }}</strong>
</div>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
</div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div>
<div class="workbench-project__footer">
<div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
<div class="workbench-project__footer">
<div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
</div>
</div>
<div class="workbench-project__footer-block workbench-project__footer-block--right">
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div>
</article>
</div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</template>
<!-- 我负责的单对象深度详情 -->
<template v-else>
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
<template v-else>
<div v-if="ownedItems.length > 1" class="ps-head">
<span class="ps-pin-label">当前项目</span>
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</article>
</div>
<div v-else class="ps-head ps-head--single">
<span class="ps-pin-label">当前项目</span>
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
<span>{{ currentOwned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ currentOwned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ currentOwned.remainingDays }} · 我的角色{{ currentOwned.myRole }}</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in currentOwned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members">
<li v-for="m in currentOwned.members" :key="m.name">
<span class="ps-member-name">{{ m.name }}</span>
<div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" />
</div>
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span>
</li>
</ul>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</template>
</template>
<!-- 我负责的单对象深度详情 -->
<template v-else>
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
<template v-else>
<div v-if="ownedItems.length > 1" class="ps-head">
<span class="ps-pin-label">当前项目</span>
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div v-else class="ps-head ps-head--single">
<span class="ps-pin-label">当前项目</span>
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
<span>{{ currentOwned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ currentOwned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div v-if="currentOwned.plannedEndDate" class="ps-sub">
计划结束 {{ currentOwned.plannedEndDate }}
<template v-if="currentOwned.remainingDays !== null">
·
{{
currentOwned.remainingDays >= 0
? `${currentOwned.remainingDays}`
: `已逾期 ${-currentOwned.remainingDays}`
}}
</template>
</div>
<div v-else class="ps-sub">未设置计划结束日期</div>
<div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members">
<li v-for="m in ownedMembersView" :key="m.userId">
<span class="ps-member-name" :title="m.userName || ''">{{ m.userName || '—' }}</span>
<div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.barPercent}%` }" />
</div>
<span class="ps-member-tasks">{{ m.activeTaskCount }}</span>
</li>
</ul>
</template>
</template>
</div>
</WorkbenchModuleCard>
</template>
@@ -186,6 +227,13 @@ function handleEnterProjectList() {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.workbench-project__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.workbench-project__desc {
@@ -202,7 +250,8 @@ function handleEnterProjectList() {
.workbench-project__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
/* 按容器宽度自适应列数而非视口minmax 180 让 w7≈588px 容器排 3 列auto-fit 平分不留白 */
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
@@ -239,6 +288,11 @@ function handleEnterProjectList() {
.workbench-project__card-title {
margin: 0;
/* 标题最长等效 10 个汉字宽度10em≈160px超出省略号hover 看完整名 */
max-width: 10em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
@@ -356,10 +410,6 @@ function handleEnterProjectList() {
gap: 4px;
}
.workbench-project__footer-block--right {
align-items: flex-end;
}
.workbench-project__footer-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
@@ -377,18 +427,6 @@ function handleEnterProjectList() {
font-weight: 600;
}
@media (width <= 1280px) {
.workbench-project__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-project__grid {
grid-template-columns: 1fr;
}
}
/* ===== 我负责的:单对象深度详情样式 ===== */
.ps-head {
display: flex;
@@ -472,32 +510,11 @@ function handleEnterProjectList() {
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-milestones,
.ps-members {
list-style: none;
margin: 0;
padding: 0;
}
.ps-milestones li {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.ps-milestones li:last-child {
border-bottom: none;
}
.ps-time {
font-size: 12px;
font-weight: 600;
}
.ps-time.tone-amber {
color: var(--el-color-warning);
}
.ps-time.tone-slate {
color: var(--el-text-color-secondary);
}
.ps-members li {
display: grid;
grid-template-columns: 60px 1fr 30px;
@@ -506,6 +523,12 @@ function handleEnterProjectList() {
padding: 4px 0;
font-size: 12px;
}
.ps-member-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
}
.ps-bar {
height: 6px;
border-radius: 3px;
@@ -514,6 +537,7 @@ function handleEnterProjectList() {
}
.ps-bar-inner {
height: 100%;
transition: width 240ms ease;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
@@ -524,7 +548,7 @@ function handleEnterProjectList() {
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
.ps-member-tasks {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;

View File

@@ -2,15 +2,17 @@
import { computed } from 'vue';
import { buildWorkbenchProjectHealthCards } from '../homepage';
import { workbenchProjectHealthMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
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 }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
@@ -32,13 +34,13 @@ const productCards: ProductHealth[] = [
<template>
<WorkbenchModuleCard
v-loading="loading"
title="产品 / 项目健康度"
icon="mdi:heart-pulse"
:badge-count="projectCards.length + productCards.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="demo-banner">
<SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" />

View File

@@ -78,6 +78,7 @@ function handleConfirm() {
direction="rtl"
size="380px"
title="选择快捷入口菜单"
append-to-body
@update:model-value="emit('update:modelValue', $event)"
>
<template #default>

View File

@@ -5,28 +5,28 @@ import { objectContextDomainConfigs } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { useRouterPush } from '@/hooks/common/router';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
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
editing: false
});
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const routeStore = useRouteStore();
const workbench = useWorkbenchStore();
const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh();
interface FlatMenu {
key: string;
label: string;
@@ -78,22 +78,26 @@ function handleClick(key: string) {
function handleConfirm(keys: string[]) {
workbench.updateModuleSettings('shortcut', { menuKeys: keys });
}
function handleRemove(key: string) {
workbench.updateModuleSettings('shortcut', { menuKeys: selectedKeys.value.filter(k => k !== key) });
}
</script>
<template>
<WorkbenchModuleCard
v-loading="loading"
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"
@refresh="refresh"
>
<div v-if="selected.length === 0" class="shortcut-empty">
<ElEmpty description="还未选择菜单" :image-size="60">
<ElEmpty description="还未选择菜单" :image-size="48">
<ElButton type="primary" size="small" @click="openPicker">+ 选择菜单</ElButton>
</ElEmpty>
</div>
@@ -104,6 +108,9 @@ function handleConfirm(keys: string[]) {
</ElIcon>
<SvgIcon v-else icon="mdi:link-variant" class="shortcut-item__icon" />
<span class="shortcut-item__label">{{ item.label }}</span>
<span class="shortcut-item__remove" title="移除此快捷入口" @click.stop="handleRemove(item.key)">
<SvgIcon icon="mdi:close" />
</span>
</button>
<button class="shortcut-item shortcut-item--add" title="添加快捷入口" @click="openPicker">
<SvgIcon icon="mdi:plus" />
@@ -120,9 +127,14 @@ function handleConfirm(keys: string[]) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
flex: 1;
min-height: 0;
overflow: auto;
align-content: start;
}
.shortcut-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@@ -137,6 +149,34 @@ function handleConfirm(keys: string[]) {
transition: all 120ms;
}
.shortcut-item__remove {
position: absolute;
top: 4px;
right: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
color: var(--el-text-color-placeholder);
font-size: 12px;
opacity: 0;
transition:
opacity 120ms,
color 120ms,
background-color 120ms;
}
.shortcut-item:hover .shortcut-item__remove {
opacity: 1;
}
.shortcut-item__remove:hover {
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.shortcut-item__icon {
font-size: 20px;
color: var(--el-color-primary);
@@ -169,6 +209,15 @@ function handleConfirm(keys: string[]) {
}
.shortcut-empty {
padding: 20px 0;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* 压缩 ElEmpty 默认大 padding空态在最小高度下也不溢出 */
.shortcut-empty :deep(.el-empty) {
padding: 12px 0;
}
</style>

View File

@@ -3,16 +3,18 @@ import { computed } from 'vue';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import { type WorkbenchTeamLoadLevel, buildWorkbenchTeamLoadView } from '../homepage';
import { workbenchTeamLoadMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamLoad' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{ (e: 'hide'): void }>();
const { loading, refresh } = useWorkbenchRefresh();
const view = computed(() => buildWorkbenchTeamLoadView(workbenchTeamLoadMock));
@@ -31,12 +33,12 @@ function urgentTooltip(dueSoon: number, overdue: number) {
<template>
<WorkbenchModuleCard
v-loading="loading"
title="团队负载"
icon="mdi:scale-balance"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="tl-kpis">
<div class="tl-kpi">
@@ -150,8 +152,9 @@ function urgentTooltip(dueSoon: number, overdue: number) {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
flex: 1;
min-height: 0;
overflow: auto;
}
.tl-list::-webkit-scrollbar {
width: 6px;

View File

@@ -22,6 +22,7 @@ import {
sortWorkbenchTodoItemsByPriority
} from '../homepage';
import { workbenchTodoMock } from '../mock';
import { useWorkbenchRefresh } from '../composables/use-workbench-refresh';
import WorkbenchModuleCard from './workbench-module-card.vue';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
@@ -36,18 +37,18 @@ defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
withDefaults(defineProps<Props>(), { editing: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
const { loading, refresh } = useWorkbenchRefresh();
const PAGE_SIZE = 5;
const activeTab = ref<WorkbenchTodoMainTab>('all');
@@ -333,12 +334,12 @@ onMounted(loadOvertimeApprovalItems);
<template>
<WorkbenchModuleCard
v-loading="loading"
title="我的待办"
icon="mdi:clipboard-text-clock-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@refresh="refresh"
>
<div class="workbench-todo__tabs">
<div class="workbench-todo__tabs-group">
@@ -493,10 +494,12 @@ onMounted(loadOvertimeApprovalItems);
/>
</div>
<!-- append-to-body脱离 grid item transform 容器弹窗才能正常全屏居中 -->
<PersonalItemOperateDialog
v-model:visible="addDialogVisible"
operate-type="add"
:row-data="null"
append-to-body
@submitted="handleAddSubmitted"
/>
@@ -691,9 +694,11 @@ onMounted(loadOvertimeApprovalItems);
}
.workbench-todo__content {
min-height: 400px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.workbench-todo__content :deep(.el-empty) {