refactor(project): 重构项目执行模块组件结构和数据管理
- 移除 execution-list-panel.vue 组件并将功能整合到执行区域 - 新增 execution-section.vue 组件替代原有的列表面板 - 将 task-workspace.vue 重命名为 task-workspace-comp.vue 并更新引用 - 引入 useTaskViewContext 组合式 API 进行任务视图上下文管理 - 添加跨执行任务状态统计接口调用和数据处理逻辑 - 重构执行状态筛选和任务创建权限判断逻辑 - 更新执行选择、搜索和重置功能的事件处理方式 - 调整页面布局结构,优化左右分栏的内容组织方式 - 完善执行详情获取和状态操作的业务流程 - 优化执行分配和状态变更的异步处理机制
This commit is contained in:
@@ -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>
|
||||
94
src/views/workbench/modules/workbench-approval.vue
Normal file
94
src/views/workbench/modules/workbench-approval.vue
Normal 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>
|
||||
@@ -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>
|
||||
121
src/views/workbench/modules/workbench-mentions.vue
Normal file
121
src/views/workbench/modules/workbench-mentions.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
122
src/views/workbench/modules/workbench-my-completion-rate.vue
Normal file
122
src/views/workbench/modules/workbench-my-completion-rate.vue
Normal 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>
|
||||
146
src/views/workbench/modules/workbench-my-execution.vue
Normal file
146
src/views/workbench/modules/workbench-my-execution.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
147
src/views/workbench/modules/workbench-my-ticket.vue
Normal file
147
src/views/workbench/modules/workbench-my-ticket.vue
Normal 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>
|
||||
100
src/views/workbench/modules/workbench-my-week-worklog.vue
Normal file
100
src/views/workbench/modules/workbench-my-week-worklog.vue
Normal 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>
|
||||
114
src/views/workbench/modules/workbench-notice-notification.vue
Normal file
114
src/views/workbench/modules/workbench-notice-notification.vue
Normal 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>
|
||||
73
src/views/workbench/modules/workbench-personal-item.vue
Normal file
73
src/views/workbench/modules/workbench-personal-item.vue
Normal 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>
|
||||
241
src/views/workbench/modules/workbench-product-snapshot.vue
Normal file
241
src/views/workbench/modules/workbench-product-snapshot.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
275
src/views/workbench/modules/workbench-project-snapshot.vue
Normal file
275
src/views/workbench/modules/workbench-project-snapshot.vue
Normal 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>
|
||||
81
src/views/workbench/modules/workbench-recent-visit.vue
Normal file
81
src/views/workbench/modules/workbench-recent-visit.vue
Normal 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>
|
||||
124
src/views/workbench/modules/workbench-risk-alert.vue
Normal file
124
src/views/workbench/modules/workbench-risk-alert.vue
Normal 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>
|
||||
108
src/views/workbench/modules/workbench-team-load.vue
Normal file
108
src/views/workbench/modules/workbench-team-load.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
112
src/views/workbench/modules/workbench-team-worklog.vue
Normal file
112
src/views/workbench/modules/workbench-team-worklog.vue
Normal 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>
|
||||
86
src/views/workbench/modules/workbench-ticket-sla.vue
Normal file
86
src/views/workbench/modules/workbench-ticket-sla.vue
Normal 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>
|
||||
111
src/views/workbench/modules/workbench-worklog-reminder.vue
Normal file
111
src/views/workbench/modules/workbench-worklog-reminder.vue
Normal 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>
|
||||
Reference in New Issue
Block a user