refactor(project): 重构项目执行模块组件结构和数据管理

- 移除 execution-list-panel.vue 组件并将功能整合到执行区域
- 新增 execution-section.vue 组件替代原有的列表面板
- 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用
- 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理
- 添加跨执行任务状态统计接口调用和数据处理逻辑
- 重构执行状态筛选和任务创建权限判断逻辑
- 更新执行选择、搜索和重置功能的事件处理方式
- 调整页面布局结构,优化左右分栏的内容组织方式
- 完善执行详情获取和状态操作的业务流程
- 优化执行分配和状态变更的异步处理机制
This commit is contained in:
2026-05-23 14:22:58 +08:00
parent 13b74cfe97
commit e9214137c1
40 changed files with 4432 additions and 1419 deletions

View File

@@ -1,157 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchActivityItems } from '../homepage';
import { workbenchActivityMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const items = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
</script>
<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">
<span class="workbench-activity__dot" :class="`workbench-activity__dot--${item.tone}`" />
<span class="workbench-activity__line" />
</div>
<div class="workbench-activity__body">
<div class="workbench-activity__meta">
<span class="workbench-activity__time" :title="item.timeLabel">{{ item.relativeLabel }}</span>
<span v-if="item.mentioned" class="workbench-activity__mention">@ 提醒</span>
</div>
<p class="workbench-activity__sentence">
<strong class="workbench-activity__actor">{{ item.actor }}</strong>
<span>{{ item.action }}</span>
<strong class="workbench-activity__target">{{ item.target }}</strong>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="暂无动态" :image-size="72" />
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-activity__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-activity__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.workbench-activity__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.workbench-activity__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 8px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.workbench-activity__dot--sky {
background-color: rgb(14 165 233 / 92%);
}
.workbench-activity__dot--emerald {
background-color: rgb(5 150 105 / 92%);
}
.workbench-activity__dot--amber {
background-color: rgb(217 119 6 / 92%);
}
.workbench-activity__dot--rose {
background-color: rgb(225 29 72 / 92%);
}
.workbench-activity__dot--violet {
background-color: rgb(124 58 237 / 92%);
}
.workbench-activity__line {
flex: 1;
width: 2px;
min-height: 28px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 24%));
}
.workbench-activity__item:last-child .workbench-activity__line {
opacity: 0;
}
.workbench-activity__body {
padding: 6px 14px 14px;
min-width: 0;
}
.workbench-activity__meta {
display: flex;
align-items: center;
gap: 8px;
}
.workbench-activity__time {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-activity__mention {
padding: 1px 8px;
border-radius: 999px;
background-color: rgb(237 233 254 / 96%);
color: rgb(109 40 217 / 96%);
font-size: 11px;
font-weight: 600;
}
.workbench-activity__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.workbench-activity__actor {
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.workbench-activity__target {
color: rgb(14 116 144 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchApproval' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ApprovalItem {
id: string;
title: string;
meta: string;
}
const items: ApprovalItem[] = [
{ id: 'a1', title: '赵六 申请 加入「风控引擎」项目', meta: '作为 开发成员 · 10min 前' },
{ id: 'a2', title: '钱七 申请 关闭执行「迭代 24.05」', meta: '含 2 个未完成任务 · 1h 前' },
{ id: 'a3', title: '孙八 申请 延期 3 天', meta: '任务「分片设计」 · 昨日' }
];
function approve(id: string) {
window.$message?.success(`已批准 ${id}mock`);
}
function reject(id: string) {
window.$message?.warning(`已驳回 ${id}mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="待审批"
icon="mdi:checkbox-multiple-marked-outline"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="approval-list">
<li v-for="item in items" :key="item.id" class="approval-item">
<div class="approval-body">
<div class="approval-title">{{ item.title }}</div>
<div class="approval-meta">{{ item.meta }}</div>
</div>
<div class="approval-actions">
<ElButton size="small" type="success" plain @click="approve(item.id)">批准</ElButton>
<ElButton size="small" type="danger" plain @click="reject(item.id)">驳回</ElButton>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.approval-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.approval-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.approval-body {
flex: 1;
min-width: 0;
}
.approval-title {
font-size: 13px;
font-weight: 500;
}
.approval-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.approval-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
</style>

View File

@@ -1,212 +0,0 @@
<script setup lang="ts">
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 {
editing?: boolean;
collapsed?: boolean;
}
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';
if (trend === 'down') return 'mdi:arrow-bottom-right-thin';
return 'mdi:minus';
}
</script>
<template>
<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>
.workbench-kpi {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.workbench-kpi__card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
min-height: 148px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 96%));
overflow: hidden;
}
.workbench-kpi__card::after {
content: '';
position: absolute;
inset: -40% -30% auto auto;
width: 160px;
height: 160px;
border-radius: 50%;
opacity: 0.55;
pointer-events: none;
}
.workbench-kpi__card--sky::after {
background: radial-gradient(circle, rgb(14 165 233 / 22%), transparent 70%);
}
.workbench-kpi__card--emerald::after {
background: radial-gradient(circle, rgb(16 185 129 / 22%), transparent 70%);
}
.workbench-kpi__card--amber::after {
background: radial-gradient(circle, rgb(245 158 11 / 22%), transparent 70%);
}
.workbench-kpi__card--rose::after {
background: radial-gradient(circle, rgb(244 63 94 / 22%), transparent 70%);
}
.workbench-kpi__card-header {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
.workbench-kpi__card-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
font-weight: 600;
}
.workbench-kpi__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
background-color: rgb(255 255 255 / 88%);
font-size: 18px;
}
.workbench-kpi__card--sky .workbench-kpi__card-icon {
color: rgb(14 116 144 / 94%);
}
.workbench-kpi__card--emerald .workbench-kpi__card-icon {
color: rgb(5 150 105 / 94%);
}
.workbench-kpi__card--amber .workbench-kpi__card-icon {
color: rgb(217 119 6 / 94%);
}
.workbench-kpi__card--rose .workbench-kpi__card-icon {
color: rgb(225 29 72 / 94%);
}
.workbench-kpi__card-value {
position: relative;
z-index: 1;
color: rgb(15 23 42 / 98%);
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.workbench-kpi__card-trend {
display: inline-flex;
align-items: center;
gap: 4px;
position: relative;
z-index: 1;
width: fit-content;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workbench-kpi__card-trend--up {
color: rgb(5 150 105 / 96%);
background-color: rgb(236 253 245 / 96%);
}
.workbench-kpi__card-trend--down {
color: rgb(225 29 72 / 96%);
background-color: rgb(255 241 242 / 96%);
}
.workbench-kpi__card-trend--flat {
color: rgb(100 116 139 / 94%);
background-color: rgb(241 245 249 / 96%);
}
.workbench-kpi__card-trend-icon {
font-size: 14px;
}
.workbench-kpi__card-hint {
margin: 0;
position: relative;
z-index: 1;
color: rgb(100 116 139 / 88%);
font-size: 12px;
line-height: 1.6;
}
@media (width <= 1280px) {
.workbench-kpi {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-kpi {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMentions' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MentionItem {
id: string;
fromName: string;
fromAvatar: string;
context: string;
timeLabel: string;
unread: boolean;
}
const items: MentionItem[] = [
{
id: 'm1',
fromName: '李四',
fromAvatar: '李',
context: '在 任务「分片设计评审」中 @ 了你',
timeLabel: '2h 前 · 评审建议',
unread: true
},
{
id: 'm2',
fromName: '张三',
fromAvatar: '张',
context: '在 执行「迭代 24.05」中 @ 了你',
timeLabel: '昨日 · 关闭确认',
unread: true
},
{
id: 'm3',
fromName: '王五',
fromAvatar: '王',
context: '在 需求「多币种支持」中 @ 了你',
timeLabel: '3 天前',
unread: true
}
];
const unreadCount = items.filter(i => i.unread).length;
</script>
<template>
<WorkbenchModuleCard
title="@我的提及"
icon="mdi:at"
:badge-count="unreadCount"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="mention-list">
<li v-for="item in items" :key="item.id" class="mention-item">
<span class="mention-avatar">{{ item.fromAvatar }}</span>
<div class="mention-body">
<div class="mention-text">
<strong>{{ item.fromName }}</strong>
{{ item.context }}
</div>
<div class="mention-meta">{{ item.timeLabel }}</div>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.mention-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.mention-item {
display: flex;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.mention-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 13px;
}
.mention-body {
min-width: 0;
flex: 1;
}
.mention-text {
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.mention-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import type { WorkbenchColumnId, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
import type {
WorkbenchColumnId,
WorkbenchModuleCategory,
WorkbenchModuleMeta
} from '../composables/use-workbench-modules';
interface Props {
modelValue: boolean;
@@ -10,6 +14,22 @@ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
}>();
const categoryLabel: Record<WorkbenchModuleCategory, string> = {
personal: '个人',
manager: '管理者',
tool: '工具',
action: '动作',
snapshot: '快照'
};
const categoryTagType: Record<WorkbenchModuleCategory, 'info' | 'warning' | 'success' | 'primary' | 'danger'> = {
personal: 'info',
manager: 'warning',
tool: 'info',
action: 'success',
snapshot: 'primary'
};
</script>
<template>
@@ -32,8 +52,8 @@ const emit = defineEmits<{
>
<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 size="small" :type="categoryTagType[meta.category]">
{{ categoryLabel[meta.category] }}
</ElTag>
</li>
</ul>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyCompletionRate' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const rate = 72; // %
const teamAvg = 65;
const total = 25;
const completed = 18;
const onTime = 13;
const overdue = 5;
const diff = rate - teamAvg;
</script>
<template>
<WorkbenchModuleCard
title="我的完成率"
icon="mdi:chart-donut"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cr-wrap">
<div class="donut" :style="{ '--p': rate } as any">
<div class="donut-inner">
<b>{{ rate }}%</b>
<span>按时完成</span>
</div>
</div>
<div class="cr-info">
<div class="cr-line">团队均值 {{ teamAvg }}%</div>
<div class="cr-line">
任务完成
<b>{{ completed }}</b>
/ {{ total }}
</div>
<div class="cr-line">
按时
<b>{{ onTime }}</b>
· 逾期
<b>{{ overdue }}</b>
</div>
<div class="cr-diff" :class="diff >= 0 ? 'text-success' : 'text-danger'">
{{ diff >= 0 ? `高于团队均值 +${diff}%` : `低于团队均值 ${diff}%` }}
</div>
</div>
</div>
<div class="cr-hint">统计近 30 </div>
</WorkbenchModuleCard>
</template>
<style scoped>
.cr-wrap {
display: flex;
align-items: center;
gap: 16px;
}
.donut {
width: 90px;
height: 90px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.donut-inner {
width: 68px;
height: 68px;
border-radius: 50%;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.donut-inner b {
font-size: 18px;
color: var(--el-text-color-primary);
}
.donut-inner span {
font-size: 10px;
color: var(--el-text-color-secondary);
}
.cr-info {
flex: 1;
font-size: 13px;
}
.cr-line {
margin: 3px 0;
}
.cr-line b {
font-weight: 700;
}
.cr-diff {
margin-top: 4px;
font-size: 12px;
}
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
.cr-hint {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ExecutionRow {
id: string;
name: string;
project: string;
done: number;
total: number;
progress: number;
statusLabel: string;
overdue: boolean;
}
const rows: ExecutionRow[] = [
{
id: 'e1',
name: '迭代 24.05',
project: '商城 V2 升级',
done: 12,
total: 15,
progress: 80,
statusLabel: '03 天后结束',
overdue: false
},
{
id: 'e2',
name: '关键路径优化',
project: '风控引擎',
done: 3,
total: 8,
progress: 38,
statusLabel: '已逾期 2 天',
overdue: true
},
{
id: 'e3',
name: '多币种支持',
project: '收银台',
done: 5,
total: 7,
progress: 71,
statusLabel: '在期内',
overdue: false
}
];
</script>
<template>
<WorkbenchModuleCard
title="我负责的执行"
icon="mdi:flag-checkered"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="exec-list">
<li v-for="row in rows" :key="row.id" class="exec-item">
<div class="exec-head">
<div class="exec-name">{{ row.name }}</div>
<div class="exec-progress">{{ row.progress }}%</div>
</div>
<div class="exec-meta">
<span>{{ row.project }}</span>
<span class="exec-sep">·</span>
<span>{{ row.done }} / {{ row.total }} 任务完成</span>
<span class="exec-sep">·</span>
<span :class="{ 'exec-overdue': row.overdue }">{{ row.statusLabel }}</span>
</div>
<div class="exec-bar">
<div class="exec-bar-inner" :class="{ 'is-overdue': row.overdue }" :style="{ width: `${row.progress}%` }" />
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.exec-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.exec-item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.exec-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.exec-name {
font-weight: 600;
font-size: 14px;
}
.exec-progress {
font-size: 16px;
font-weight: 700;
color: var(--el-color-primary);
}
.exec-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.exec-sep {
margin: 0 6px;
color: var(--el-text-color-placeholder);
}
.exec-overdue {
color: var(--el-color-danger);
font-weight: 600;
}
.exec-bar {
height: 6px;
border-radius: 999px;
background: var(--el-fill-color);
overflow: hidden;
}
.exec-bar-inner {
height: 100%;
background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-primary-light-3));
transition: width 240ms ease;
}
.exec-bar-inner.is-overdue {
background: linear-gradient(90deg, var(--el-color-danger), var(--el-color-danger-light-3));
}
</style>

View File

@@ -1,105 +1,107 @@
<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';
defineOptions({ name: 'WorkbenchMyTask' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void; (e: 'refresh'): void }>();
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): 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');
interface FocusItem {
id: string;
title: string;
done: boolean;
}
function handleRefresh() {
window.$message?.success('已刷新v1 mock');
}
const items = ref<FocusItem[]>([
{ id: 'f1', title: '提交昨日工时', done: true },
{ id: 'f2', title: '登录页 SSO 改造 · 18:00 截止', done: false },
{ id: 'f3', title: '迭代 24.05 关闭 · 跟交付 / QA 确认遗留', done: false }
]);
const doneCount = computed(() => items.value.filter(i => i.done).length);
const totalCount = computed(() => items.value.length);
const todayHours = 3.5;
const targetHours = 8;
</script>
<template>
<WorkbenchModuleCard
title="我的任务"
icon="mdi:checkbox-marked-circle-outline"
:badge-count="visibleItems.length"
title="我的今日"
icon="mdi:calendar-check-outline"
: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>
<div class="today-head">
<span class="today-progress">{{ doneCount }} / {{ totalCount }} 已完成</span>
</div>
<ul class="today-list">
<li v-for="item in items" :key="item.id" class="today-row">
<ElCheckbox v-model="item.done" />
<span class="today-text" :class="{ 'today-done': item.done }">{{ item.title }}</span>
</li>
</ul>
<ElEmpty v-else description="暂无任务" :image-size="60" />
<div class="today-foot">
当日累计工时
<b>{{ todayHours }}h</b>
/ {{ targetHours }}h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.my-task-tabs {
margin-bottom: 4px;
.today-head {
display: flex;
justify-content: flex-end;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.my-task-list {
.today-progress {
padding: 2px 8px;
background: var(--el-fill-color-lighter);
border-radius: 999px;
}
.today-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;
.today-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.my-task-item:hover {
background: var(--el-color-primary-light-9);
.today-row:last-child {
border-bottom: none;
}
.my-task-title {
font-weight: 500;
.today-text {
font-size: 13px;
}
.my-task-meta {
grid-column: 2 / 3;
.today-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
.today-foot {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
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;
.today-foot b {
color: var(--el-text-color-primary);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyTicket' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
type Tone = 'rose' | 'amber' | 'slate';
interface TicketRow {
id: string;
title: string;
product: string;
priorityLabel: string;
priorityTone: Tone;
slaLabel: string;
slaTone: Tone;
}
const rows: TicketRow[] = [
{
id: 't1',
title: '商户后台登录异常',
product: '商户后台',
priorityLabel: '高',
priorityTone: 'rose',
slaLabel: '超时 2h',
slaTone: 'rose'
},
{
id: 't2',
title: '报表导出失败',
product: '数据中心',
priorityLabel: '中',
priorityTone: 'amber',
slaLabel: '剩 4h',
slaTone: 'amber'
},
{
id: 't3',
title: '移动端推送延迟',
product: '用户中心',
priorityLabel: '低',
priorityTone: 'slate',
slaLabel: '剩 2 天',
slaTone: 'slate'
}
];
</script>
<template>
<WorkbenchModuleCard
title="我负责的工单"
icon="mdi:ticket-confirmation-outline"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="warning" :closable="false" class="pending-hint">
工单业务暂未上线当前为 mock 数据正式接口落地后接通
</ElAlert>
<ul class="ticket-list">
<li v-for="row in rows" :key="row.id" class="ticket-item">
<span class="ticket-priority" :class="`tone-${row.priorityTone}`">{{ row.priorityLabel }}</span>
<div class="ticket-body">
<div class="ticket-title">{{ row.title }}</div>
<div class="ticket-meta">{{ row.product }}</div>
</div>
<span class="ticket-sla" :class="`tone-${row.slaTone}`">{{ row.slaLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.pending-hint {
margin-bottom: 10px;
}
.ticket-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.ticket-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.ticket-priority {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
.ticket-body {
min-width: 0;
}
.ticket-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ticket-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.ticket-sla {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.tone-rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.tone-amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.tone-slate {
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
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 }>();
const days = ['周一', '周二', '周三', '周四', '周五', '今日'];
const hoursPerDay = [4, 7, 6, 8, 7.5, 0]; // 今日为 0未填报
const total = hoursPerDay.reduce((s, h) => s + h, 0);
const target = 40;
const todayProgress = 32.5; // 截至当前小时数(含历史 + 今日已提交部分)
const delta = todayProgress - target * 0.75; // 目标按周 75% 进度
const deltaText = delta >= 0 ? `领先目标 +${delta.toFixed(1)}h` : `落后目标 ${delta.toFixed(1)}h`;
const deltaClass = delta >= 0 ? 'text-success' : 'text-danger';
// 折线坐标计算(基于 200x80 viewBox
const padX = 10;
const padY = 10;
const width = 200;
const height = 80;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const maxY = 10;
const points = hoursPerDay.map((h, i) => {
const x = padX + (i / (hoursPerDay.length - 1)) * innerW;
const y = padY + innerH - (h / maxY) * innerH;
return { x: Number(x.toFixed(1)), y: Number(y.toFixed(1)) };
});
const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
</script>
<template>
<WorkbenchModuleCard
title="我的本周工时"
icon="mdi:chart-line"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<svg viewBox="0 0 200 80" preserveAspectRatio="none" class="spark">
<line x1="0" y1="20" x2="200" y2="20" stroke="var(--el-border-color-lighter)" stroke-dasharray="3,3" />
<polyline :points="polyline" fill="none" stroke="var(--el-color-primary)" stroke-width="2" />
<circle v-for="(p, i) in points" :key="i" :cx="p.x" :cy="p.y" r="3" fill="var(--el-color-primary)" />
</svg>
<div class="ww-x">
<span v-for="d in days" :key="d">{{ d }}</span>
</div>
<div class="ww-foot">
<span>
累计
<b>{{ todayProgress }}h</b>
/ {{ target }}h
</span>
<span :class="deltaClass">{{ deltaText }}</span>
</div>
<div class="ww-hint">本周总和含今日{{ total.toFixed(1) }}h</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.spark {
width: 100%;
height: 80px;
display: block;
}
.ww-x {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.ww-foot {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-top: 10px;
}
.ww-foot b {
font-weight: 700;
}
.ww-hint {
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
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 Row {
id: string;
title: string;
timeLabel: string;
}
const notices: Row[] = [
{ 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: Row[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前' },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前' },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日' }
];
</script>
<template>
<WorkbenchModuleCard
title="公告 + 通知"
icon="mdi:bullhorn-outline"
:badge-count="notifications.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<div class="nn-col">
<div class="nn-h">📢 公告</div>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
</li>
</ul>
</div>
<div class="nn-col">
<div class="nn-h">🔔 系统通知未读 {{ notifications.length }}</div>
<ul class="nn-list">
<li v-for="row in notifications" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
</li>
</ul>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.nn-col {
min-width: 0;
}
.nn-h {
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 600;
margin-bottom: 6px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
}
.nn-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
gap: 8px;
}
.nn-list li:last-child {
border-bottom: none;
}
.nn-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nn-time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
@media (width <= 1280px) {
.nn-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchPersonalItem' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ItemRow {
id: string;
title: string;
done: boolean;
}
const items = ref<ItemRow[]>([
{ id: 'p1', title: '周会准备 · 跨境支付方案 PPT', done: false },
{ id: 'p2', title: '整理 Q2 OKR 复盘材料', done: false },
{ id: 'p3', title: '回复采购系统迁移邮件', done: true },
{ id: 'p4', title: '技术分享话题征集', done: false },
{ id: 'p5', title: '新人 Onboarding · 文档整理', done: false }
]);
</script>
<template>
<WorkbenchModuleCard
title="我的个人事项"
icon="mdi:format-list-checks"
:badge-count="items.filter(i => !i.done).length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="item-list">
<li v-for="item in items" :key="item.id" class="item-row">
<ElCheckbox v-model="item.done" />
<span class="item-text" :class="{ 'item-done': item.done }">{{ item.title }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.item-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.item-row:last-child {
border-bottom: none;
}
.item-text {
font-size: 13px;
}
.item-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref } from 'vue';
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 }>();
interface ProductOption {
id: string;
name: string;
status: string;
activeRequirement: number;
relatedProject: number;
memberCount: number;
blockCount: number;
myRole: string;
requirement: { pending: number; inDev: number; inAccept: number; done: number };
activities: Array<{ id: string; title: string; timeLabel: string }>;
}
const products: ProductOption[] = [
{
id: 'pr1',
name: '商户后台',
status: '迭代中',
activeRequirement: 23,
relatedProject: 3,
memberCount: 12,
blockCount: 2,
myRole: '产品负责人',
requirement: { pending: 6, inDev: 8, inAccept: 5, done: 4 },
activities: [
{ id: 'a1', title: '需求「多币种支持」评审通过', timeLabel: '2h 前' },
{ id: 'a2', title: '需求「订单流水查询」拆出 5 个任务', timeLabel: '昨日' },
{ id: 'a3', title: '需求「报表升级」状态:待评审 → 开发中', timeLabel: '2 天前' }
]
},
{
id: 'pr2',
name: '收银台',
status: '迭代中',
activeRequirement: 8,
relatedProject: 2,
memberCount: 5,
blockCount: 0,
myRole: '协作产品',
requirement: { pending: 2, inDev: 3, inAccept: 2, done: 1 },
activities: [
{ id: 'a4', title: '需求「多币种支持」开发中', timeLabel: '1h 前' },
{ id: 'a5', title: '汇率服务接入排期确认', timeLabel: '昨日' }
]
}
];
const pinnedId = ref(products[0].id);
const pinned = ref(products[0]);
function onChange(id: string) {
const found = products.find(p => p.id === id);
if (found) pinned.value = found;
}
</script>
<template>
<WorkbenchModuleCard
title="产品深度快照"
icon="mdi:image-area-close"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div class="ps-overview">
<ElTag type="success" effect="dark" size="small">{{ pinned.status }}</ElTag>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ pinned.activeRequirement }}</b>
<span>活跃需求</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.relatedProject }}</b>
<span>关联项目</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.memberCount }}</b>
<span>团队成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-warn': pinned.blockCount > 0 }">{{ pinned.blockCount }}</b>
<span>阻塞</span>
</div>
</div>
</div>
<div class="ps-sub">我的角色{{ pinned.myRole }}</div>
<div class="ps-section-title">📋 需求状态分布</div>
<div class="ps-req">
<div class="ps-req-cell">
<b>{{ pinned.requirement.pending }}</b>
<span>待评审</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.inDev }}</b>
<span>开发中</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.inAccept }}</b>
<span>验收中</span>
</div>
<div class="ps-req-cell">
<b>{{ pinned.requirement.done }}</b>
<span>已发布</span>
</div>
</div>
<div class="ps-section-title">🔄 近期动态</div>
<ul class="ps-act">
<li v-for="a in pinned.activities" :key="a.id">
<span class="ps-act-title">{{ a.title }}</span>
<span class="ps-act-time">{{ a.timeLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 180px;
}
.ps-overview {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
}
.ps-kpi b.is-warn {
color: var(--el-color-warning);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-req {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ps-req-cell {
background: var(--el-fill-color-lighter);
border-radius: 6px;
padding: 8px;
text-align: center;
}
.ps-req-cell b {
display: block;
font-size: 18px;
font-weight: 700;
}
.ps-req-cell span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-act {
list-style: none;
margin: 0;
padding: 0;
}
.ps-act li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
}
.ps-act li:last-child {
border-bottom: none;
}
.ps-act-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.ps-act-time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

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

View File

@@ -12,21 +12,37 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
const projectCards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
// 产品维度 mock蓝图 C15 要求扩到产品维度,未来后端接口落地后接通)
interface ProductHealth {
id: string;
name: string;
health: 'green' | 'yellow' | 'red';
healthLabel: string;
activeRequirement: number;
blockCount: number;
}
const productCards: ProductHealth[] = [
{ id: 'pr1', name: '商户后台', health: 'green', healthLabel: '良好', activeRequirement: 15, blockCount: 0 },
{ id: 'pr2', name: '收银台', health: 'green', healthLabel: '良好', activeRequirement: 8, blockCount: 0 },
{ id: 'pr3', name: '用户中心', health: 'yellow', healthLabel: '关注', activeRequirement: 11, blockCount: 1 }
];
</script>
<template>
<WorkbenchModuleCard
title="项目健康度"
title="产品 / 项目健康度"
icon="mdi:heart-pulse"
:badge-count="cards.length"
:badge-count="projectCards.length + productCards.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="section-title">项目</div>
<div class="health-list">
<div v-for="card in cards" :key="card.projectId" class="health-card">
<div v-for="card in projectCards" :key="card.projectId" class="health-card">
<div class="health-card__ring" :class="`is-${card.health}`">
<span>{{ card.healthLabel }}</span>
</div>
@@ -40,10 +56,36 @@ const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHe
</div>
</div>
</div>
<div class="section-title section-title--gap">产品</div>
<div class="health-list">
<div v-for="card in productCards" :key="card.id" 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.name }}</div>
<div class="health-card__meta">
<ElTag size="small">活跃需求 {{ card.activeRequirement }}</ElTag>
<ElTag v-if="card.blockCount > 0" size="small" type="warning">阻塞 {{ card.blockCount }}</ElTag>
<ElTag v-else size="small" type="success">无阻塞</ElTag>
</div>
</div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.section-title--gap {
margin-top: 14px;
}
.health-list {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectSnapshot' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ProjectOption {
id: string;
name: string;
progress: number;
executionCount: number;
taskCount: number;
memberCount: number;
overdueCount: number;
remainingDays: number;
myRole: string;
milestones: Array<{ id: string; title: string; timeLabel: string; tone: 'amber' | 'slate' }>;
members: Array<{ name: string; load: number; level: 'ok' | 'warn' | 'over' }>;
}
const projects: ProjectOption[] = [
{
id: 'p1',
name: '商城 V2 升级',
progress: 70,
executionCount: 5,
taskCount: 32,
memberCount: 6,
overdueCount: 1,
remainingDays: 12,
myRole: '负责人',
milestones: [
{ id: 'm1', title: 'SSO 改造提测', timeLabel: '今日 18:00', tone: 'amber' },
{ id: 'm2', title: '迭代 24.05 关闭', timeLabel: '今日', tone: 'amber' },
{ id: 'm3', title: '多币种支持评审', timeLabel: '05-26', tone: 'slate' }
],
members: [
{ name: '张三', load: 50, level: 'ok' },
{ name: '李四', load: 30, level: 'ok' },
{ name: '王五', load: 90, level: 'over' }
]
},
{
id: 'p2',
name: '风控引擎接入',
progress: 45,
executionCount: 3,
taskCount: 18,
memberCount: 4,
overdueCount: 2,
remainingDays: 30,
myRole: '协办人',
milestones: [
{ id: 'm4', title: '分片设计评审', timeLabel: '明日', tone: 'amber' },
{ id: 'm5', title: '缓存穿透优化交付', timeLabel: '05-28', tone: 'slate' }
],
members: [
{ name: '李四', load: 30, level: 'ok' },
{ name: '钱七', load: 65, level: 'warn' }
]
}
];
const pinnedId = ref(projects[0].id);
const pinned = ref(projects[0]);
function onChange(id: string) {
const found = projects.find(p => p.id === id);
if (found) pinned.value = found;
}
</script>
<template>
<WorkbenchModuleCard
title="项目深度快照"
icon="mdi:image-area"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': pinned.progress } as any">
<span>{{ pinned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ pinned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': pinned.overdueCount > 0 }">{{ pinned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ pinned.remainingDays }} · 我的角色{{ pinned.myRole }}</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in pinned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div v-if="pinned.myRole === '负责人'" class="ps-section-title">👥 成员负载</div>
<ul v-if="pinned.myRole === '负责人'" class="ps-members">
<li v-for="m in pinned.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>
</WorkbenchModuleCard>
</template>
<style scoped>
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 180px;
}
.ps-overview {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-ring {
width: 50px;
height: 50px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ps-ring span {
background: #fff;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.ps-kpi b.is-danger {
color: var(--el-color-danger);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
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;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
}
.ps-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.ps-bar-inner {
height: 100%;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
}
.ps-bar-inner.is-warn {
background: var(--el-color-warning);
}
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRecentVisit' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface VisitRow {
id: string;
title: string;
type: '项目' | '执行' | '产品' | '需求';
timeLabel: string;
}
const rows: VisitRow[] = [
{ id: 'v1', title: '商城 V2 升级', type: '项目', timeLabel: '2h 前' },
{ id: 'v2', title: '迭代 24.05', type: '执行', timeLabel: '今日' },
{ id: 'v3', title: '分片设计评审', type: '需求', timeLabel: '昨日' },
{ id: 'v4', title: '收银台', type: '产品', timeLabel: '2 天前' },
{ id: 'v5', title: '风控引擎接入', type: '项目', timeLabel: '3 天前' }
];
function typeTag(t: VisitRow['type']): 'primary' | 'success' | 'warning' | 'info' {
return ({ 项目: 'primary', 执行: 'success', 产品: 'warning', 需求: 'info' } as const)[t];
}
</script>
<template>
<WorkbenchModuleCard
title="最近访问"
icon="mdi:history"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="visit-list">
<li v-for="row in rows" :key="row.id" class="visit-item">
<ElTag size="small" :type="typeTag(row.type)">{{ row.type }}</ElTag>
<span class="visit-title">{{ row.title }}</span>
<span class="visit-time">{{ row.timeLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.visit-list {
list-style: none;
margin: 0;
padding: 0;
}
.visit-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.visit-item:last-child {
border-bottom: none;
}
.visit-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.visit-time {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRiskAlert' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface Stat {
n: number;
label: string;
tone: 'rose' | 'amber' | 'sky';
}
interface RiskRow {
id: string;
title: string;
owner: string;
sub: string;
tone: 'rose' | 'amber';
}
const stats: Stat[] = [
{ n: 3, label: '逾期任务', tone: 'rose' },
{ n: 2, label: '停滞执行 (>7d)', tone: 'amber' },
{ n: 2, label: '超时未关闭工单', tone: 'rose' }
];
const rows: RiskRow[] = [
{ id: 'r1', title: '缓存穿透优化', owner: '李四', sub: '逾期 2 天', tone: 'rose' },
{ id: 'r2', title: '商户后台登录异常', owner: '张三', sub: 'SLA 超 2h', tone: 'rose' },
{ id: 'r3', title: '关键路径优化', owner: '风控引擎', sub: '停滞 9 天', tone: 'amber' }
];
</script>
<template>
<WorkbenchModuleCard
title="风险预警"
icon="mdi:alert-octagon-outline"
:badge-count="stats.reduce((s, x) => s + x.n, 0)"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="risk-grid">
<div v-for="s in stats" :key="s.label" class="risk-cell" :class="`tone-${s.tone}`">
<div class="risk-n">{{ s.n }}</div>
<div class="risk-lbl">{{ s.label }}</div>
</div>
</div>
<ul class="risk-list">
<li v-for="row in rows" :key="row.id" class="risk-row">
<span class="risk-title" :class="`tone-${row.tone}`">{{ row.title }} · {{ row.owner }}</span>
<span class="risk-sub">{{ row.sub }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.risk-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.risk-cell {
padding: 10px;
border-radius: 8px;
text-align: left;
}
.risk-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.risk-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.risk-cell.tone-sky {
background: #f0f9ff;
color: #075985;
}
.risk-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.risk-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.risk-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.risk-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
}
.risk-row:last-child {
border-bottom: none;
}
.risk-title.tone-rose {
color: var(--el-color-danger);
}
.risk-title.tone-amber {
color: var(--el-color-warning);
}
.risk-sub {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
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 }>();
interface LoadRow {
name: string;
inProgress: number;
}
const rows: LoadRow[] = [
{ name: '张三', inProgress: 5 },
{ name: '李四', inProgress: 3 },
{ name: '王五', inProgress: 7 },
{ name: '赵六', inProgress: 2 },
{ name: '钱七', inProgress: 5 }
];
const MAX = 10;
function level(n: number): 'ok' | 'warn' | 'over' {
if (n >= 6) return 'over';
if (n >= 4) return 'warn';
return 'ok';
}
</script>
<template>
<WorkbenchModuleCard
title="团队负载"
icon="mdi:scale-balance"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="load-list">
<li v-for="row in rows" :key="row.name" class="load-item">
<span class="load-name">{{ row.name }}</span>
<div class="load-bar">
<div
class="load-bar-inner"
:class="`is-${level(row.inProgress)}`"
:style="{ width: `${(row.inProgress / MAX) * 100}%` }"
/>
</div>
<span class="load-n" :class="{ 'text-danger': level(row.inProgress) === 'over' }">
{{ row.inProgress }}{{ level(row.inProgress) === 'over' ? ' ⚠' : '' }}
</span>
</li>
</ul>
<div class="load-hint">阈值 6 高负载 · 4 中负载</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.load-list {
list-style: none;
margin: 0;
padding: 0;
}
.load-item {
display: grid;
grid-template-columns: 60px 1fr 50px;
align-items: center;
gap: 10px;
padding: 6px 0;
font-size: 13px;
}
.load-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.load-bar-inner {
height: 100%;
}
.load-bar-inner.is-ok {
background: var(--el-color-success);
}
.load-bar-inner.is-warn {
background: var(--el-color-warning);
}
.load-bar-inner.is-over {
background: var(--el-color-danger);
}
.load-n {
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.load-hint {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,47 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildWorkbenchTeamTodoRows } from '../homepage';
import { workbenchTeamTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamTodo' });
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));
interface KanbanTask {
id: string;
title: string;
priority: '高' | '中' | '低';
deadline: string;
overdue?: boolean;
}
interface MemberColumn {
name: string;
total: number;
warn?: boolean;
tasks: KanbanTask[];
more?: number;
}
const columns: MemberColumn[] = [
{
name: '张三',
total: 5,
tasks: [
{ id: 't1', title: '登录页 SSO 改造', priority: '高', deadline: '今日截止' },
{ id: 't2', title: '用户中心头像上传', priority: '中', deadline: '05-27' },
{ id: 't3', title: '登录日志埋点', priority: '低', deadline: '06-01' }
]
},
{
name: '李四',
total: 3,
tasks: [
{ id: 't4', title: '分片设计评审', priority: '中', deadline: '明日' },
{ id: 't5', title: '缓存穿透优化', priority: '高', deadline: '已逾期', overdue: true }
]
},
{
name: '王五',
total: 7,
warn: true,
tasks: [
{ id: 't6', title: '多币种支持开发', priority: '高', deadline: '05-26' },
{ id: 't7', title: '汇率服务接入', priority: '中', deadline: '05-28' }
],
more: 5
},
{
name: '赵六',
total: 2,
tasks: [
{ id: 't8', title: '风控规则配置化', priority: '中', deadline: '05-29' },
{ id: 't9', title: '规则引擎压测', priority: '低', deadline: '06-05' }
]
}
];
function priorityClass(p: KanbanTask['priority']) {
return ({ : 'tone-rose', : 'tone-amber', : 'tone-slate' } as const)[p];
}
</script>
<template>
<WorkbenchModuleCard
title="团队待办汇总"
icon="mdi:account-group-outline"
:badge-count="rows.length"
title="团队任务看板"
icon="mdi:view-column-outline"
: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>
<div class="kanban">
<div v-for="col in columns" :key="col.name" class="kanban-col">
<div class="kanban-h">
<span>{{ col.name }}</span>
<span class="kanban-total" :class="{ 'is-warn': col.warn }">{{ col.total }}{{ col.warn ? ' ⚠' : '' }}</span>
</div>
<div v-for="task in col.tasks" :key="task.id" class="kanban-task">
<div class="kanban-title">{{ task.title }}</div>
<div class="kanban-meta">
<span class="kanban-pri" :class="priorityClass(task.priority)">{{ task.priority }}</span>
<span :class="{ 'text-danger': task.overdue }">{{ task.deadline }}</span>
</div>
</div>
<div v-if="col.more" class="kanban-more">+{{ col.more }} </div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.kanban-col {
background: var(--el-fill-color-lighter);
border-radius: 6px;
padding: 8px;
min-height: 140px;
}
.kanban-h {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 600;
}
.kanban-total {
background: var(--el-bg-color);
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
}
.kanban-total.is-warn {
color: var(--el-color-danger);
font-weight: 700;
}
.kanban-task {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 5px;
padding: 6px;
margin-bottom: 6px;
font-size: 11px;
}
.kanban-title {
font-weight: 500;
margin-bottom: 4px;
}
.kanban-meta {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-text-color-secondary);
font-size: 10px;
}
.kanban-pri {
padding: 0 4px;
border-radius: 4px;
font-weight: 700;
}
.tone-rose {
background: #fee2e2;
color: #991b1b;
}
.tone-amber {
background: #fef3c7;
color: #92400e;
}
.tone-slate {
background: #f1f5f9;
color: #475569;
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.kanban-more {
font-size: 11px;
color: var(--el-color-danger);
text-align: center;
padding: 4px;
}
@media (width <= 1280px) {
.kanban {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamWorklog' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MemberRow {
name: string;
hours: number;
}
const members: MemberRow[] = [
{ name: '张三', hours: 38 },
{ name: '李四', hours: 42 },
{ name: '王五', hours: 30 },
{ name: '赵六', hours: 48 },
{ name: '钱七', hours: 25 }
];
const maxHours = 48;
const avg = (members.reduce((s, m) => s + m.hours, 0) / members.length).toFixed(1);
const lowest = members.reduce((a, b) => (a.hours < b.hours ? a : b));
const highest = members.reduce((a, b) => (a.hours > b.hours ? a : b));
</script>
<template>
<WorkbenchModuleCard
title="团队工时分布"
icon="mdi:chart-bar"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="bars">
<div v-for="m in members" :key="m.name" class="bar-col">
<div class="bar-value">{{ m.hours }}h</div>
<div class="bar" :style="{ height: `${(m.hours / maxHours) * 100}%` }" />
</div>
</div>
<div class="bars-x">
<div v-for="m in members" :key="m.name">{{ m.name }}</div>
</div>
<div class="bars-hint">
平均 {{ avg }}h / ·
<span class="text-danger">{{ lowest.name }}</span>
工时偏低 ·
<span class="text-warn">{{ highest.name }}</span>
40h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.bars {
display: flex;
align-items: flex-end;
gap: 10px;
height: 120px;
padding: 18px 4px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
position: relative;
}
.bar-value {
position: absolute;
top: -16px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.bar {
width: 100%;
background: linear-gradient(180deg, var(--el-color-primary), var(--el-color-primary-light-7));
border-radius: 4px 4px 0 0;
min-height: 6px;
}
.bars-x {
display: flex;
gap: 10px;
margin-top: 4px;
}
.bars-x div {
flex: 1;
text-align: center;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.bars-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
}
.text-warn {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTicketSla' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const unclosed = 12;
const overtime = 3;
const willOvertime = 5;
const byPriority = { high: 4, mid: 6, low: 2 };
</script>
<template>
<WorkbenchModuleCard
title="工单 SLA 总览"
icon="mdi:timer-alert-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="warning" :closable="false" class="pending-hint">
工单业务暂未上线当前为 mock 数据正式接口落地后接通
</ElAlert>
<div class="sla-grid">
<div class="sla-cell tone-rose">
<div class="sla-n">{{ unclosed }}</div>
<div class="sla-lbl">未关闭</div>
</div>
<div class="sla-cell tone-rose">
<div class="sla-n">{{ overtime }}</div>
<div class="sla-lbl">超时</div>
</div>
<div class="sla-cell tone-amber">
<div class="sla-n">{{ willOvertime }}</div>
<div class="sla-lbl">将超时</div>
</div>
</div>
<div class="sla-hint">按优先级 {{ byPriority.high }} · {{ byPriority.mid }} · {{ byPriority.low }}</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.pending-hint {
margin-bottom: 10px;
}
.sla-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.sla-cell {
padding: 12px;
border-radius: 8px;
text-align: left;
}
.sla-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.sla-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.sla-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.sla-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.sla-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchWorklogReminder' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface DayRow {
dateLabel: string;
status: 'filled' | 'pending' | 'missing';
hours: number;
}
const today = '今日未填报';
const todayHint = '已经 14:30 了,记得填工时';
const recent: DayRow[] = [
{ dateLabel: '05-21', status: 'filled', hours: 8 },
{ dateLabel: '05-20', status: 'filled', hours: 7.5 },
{ dateLabel: '05-19', status: 'missing', hours: 0 }
];
function fillNow() {
window.$message?.info('跳转工时填报mock');
}
function fillMakeup(label: string) {
window.$message?.info(`补填 ${label} 工时mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="工时填报提醒"
icon="mdi:timer-sand"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cta">
<div class="cta-text">
<div class="cta-title">{{ today }}</div>
<div class="cta-hint">{{ todayHint }}</div>
</div>
<ElButton type="primary" size="small" @click="fillNow">立即填报</ElButton>
</div>
<ul class="recent">
<li v-for="row in recent" :key="row.dateLabel" class="recent-item">
<span class="recent-date">{{ row.dateLabel }}</span>
<span v-if="row.status === 'filled'" class="recent-hours">已填 {{ row.hours }}h</span>
<ElButton v-else size="small" type="danger" link @click="fillMakeup(row.dateLabel)">补填</ElButton>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-dark-2));
color: #fff;
margin-bottom: 10px;
}
.cta-text {
min-width: 0;
}
.cta-title {
font-size: 14px;
font-weight: 700;
}
.cta-hint {
font-size: 12px;
opacity: 0.9;
margin-top: 2px;
}
.recent {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 6px;
}
.recent-item:hover {
background: var(--el-fill-color-lighter);
}
.recent-date {
font-size: 13px;
}
.recent-hours {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>