devperf/frontend/src/views/RoiDashboard.vue
zyc 4a2ed8d414 feat(ui+perf): Editorial Data Console 重设计 + 接口性能 + ROI 权限锁
UI 重设计 (Editorial Data Console 风):
- 设计令牌系统: OKLCH 色彩 + Newsreader/Geist/JetBrains Mono 字体 + exp easing
- 全局表格基线 (.n-data-table 统一 editorial 风 + .table-shell 卡片容器)
- DataCard / Naive UI 主题对齐新 token (深墨青主色 + 暖琥珀强调)
- RoiDashboard: 3 KPI 卡片同字号 + chip 多色筛选 + section editorial 节奏
- ProjectRoiBoard: hero 卡 highlight + ytd-strip 节奏化 (10/13/15px 三层字号)
- ProjectList: 自适应卡片 + 产品线 NSelect 筛选 + 拆出独立"类型"列 + 文本链接操作
- RevenuePieChart 重设计: donut + 中心总额 + 底部水平图例 (替代外部 callout 截断)
- 全部页面 width:100% + clamp() 流体 padding,断点驱动 auto-fit 网格
- AppSidebar 项目子菜单按产品线分组 + 可折叠 + localStorage 持久化

接口性能优化 (N+1 → 批量 + Map 索引):
- /api/overview: 8.5s → 0.5s (17×) - 消除 3 处循环 SQL 查询
- /api/okr:     11.3s → 0.3s (37×) - getOKRByPeriod 一次性 inArray 批量
- ROI 三处时间窗 (aggregate/timeseries/events) launchedAt 截断对齐

ROI 权限锁:
- 全部 ROI 端点统一 admin (roiRoutes 全局 requireRole)
- 路由 /roi + /projects/:id/roi meta.roles=['admin']
- 侧边栏 ROI 入口 + 项目详情打标按钮/分类标签全部 v-if isAdmin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:28:48 +08:00

489 lines
17 KiB
Vue

<script setup lang="ts">
import { h, ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import {
NSpin, NCard, NSelect, NEmpty, NDataTable, NTag, NSwitch, NButton, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import { fetchDashboard, type DashboardResult } from '@/api/roi';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import CategoryStackedBar from '@/components/roi/CategoryStackedBar.vue';
import RevenuePieChart from '@/components/roi/RevenuePieChart.vue';
const router = useRouter();
const message = useMessage();
const loading = ref(true);
type WindowKey = 'lifetime' | 'ytd' | 'mtd' | 'custom';
const windowKey = ref<WindowKey>('lifetime');
function rangeOf(key: WindowKey): { from: string; to: string } {
const today = dayjs().format('YYYY-MM-DD');
if (key === 'lifetime') return { from: '2020-01-01', to: today };
if (key === 'ytd') return { from: dayjs().startOf('year').format('YYYY-MM-DD'), to: today };
return { from: dayjs().startOf('month').format('YYYY-MM-DD'), to: today };
}
const lifetimeData = ref<DashboardResult | null>(null);
const ytdData = ref<DashboardResult | null>(null);
const mtdData = ref<DashboardResult | null>(null);
async function loadAll() {
loading.value = true;
try {
const [l, y, m] = await Promise.all([
fetchDashboard(rangeOf('lifetime').from, rangeOf('lifetime').to),
fetchDashboard(rangeOf('ytd').from, rangeOf('ytd').to),
fetchDashboard(rangeOf('mtd').from, rangeOf('mtd').to),
]);
lifetimeData.value = l.data.data;
ytdData.value = y.data.data;
mtdData.value = m.data.data;
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
const activeData = computed(() => {
if (windowKey.value === 'lifetime') return lifetimeData.value;
if (windowKey.value === 'ytd') return ytdData.value;
return mtdData.value;
});
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return '#0D9668';
if (n >= 0) return '#D4920A';
return '#DC2626';
}
const CATEGORY_META: Record<string, { emoji: string; label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = {
cash_cow: { emoji: '💰', label: '现金牛', color: 'success' },
efficiency_tool: { emoji: '⚙️', label: '效能工具', color: 'info' },
moat: { emoji: '💎', label: '资本护城河', color: 'warning' },
composite: { emoji: '🚀', label: '复合型', color: 'error' },
uncategorized: { emoji: '◯', label: '未打标', color: 'default' },
};
// 筛选状态
const selectedCategories = ref<string[]>([]); // 空数组 = 全部
const hideZeroCost = ref(true);
function toggleCategory(cat: string) {
const i = selectedCategories.value.indexOf(cat);
if (i >= 0) selectedCategories.value.splice(i, 1);
else selectedCategories.value.push(cat);
}
function chipColor(t: string): string {
return ({ success: '#0D9668', info: '#3B5998', warning: '#D4920A', error: '#DC2626', default: '#666' } as Record<string, string>)[t] || '#666';
}
// 按分类计数(基于当前活动窗口)
const categoryStats = computed(() => {
const stats: Record<string, { count: number; totalCost: number; totalRevenue: number }> = {};
for (const k of Object.keys(CATEGORY_META)) {
stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
}
const items = activeData.value?.projects || [];
for (const p of items) {
const k = p.category || 'uncategorized';
if (!stats[k]) stats[k] = { count: 0, totalCost: 0, totalRevenue: 0 };
stats[k].count += 1;
stats[k].totalCost += p.totalCost;
stats[k].totalRevenue += p.totalRevenue;
}
return stats;
});
// 经过筛选/排序的项目列表
const filteredProjects = computed(() => {
let items = (activeData.value?.projects || []).slice();
if (selectedCategories.value.length > 0) {
items = items.filter(p => selectedCategories.value.includes(p.category || 'uncategorized'));
}
if (hideZeroCost.value) {
items = items.filter(p => p.totalCost > 0);
}
return items;
});
const projectColumns = [
{
title: '项目', key: 'name',
render: (row: any) => row.name,
sorter: (a: any, b: any) => (a.name || '').localeCompare(b.name || ''),
},
{
title: '定位', key: 'category',
render: (row: any) => {
const meta = CATEGORY_META[row.category || 'uncategorized'];
return h(NTag, { type: meta.color, size: 'small', round: true }, () => `${meta.emoji} ${meta.label}`);
},
filterOptions: Object.entries(CATEGORY_META).map(([k, v]) => ({ label: `${v.emoji} ${v.label}`, value: k })),
filter: (value: any, row: any) => (row.category || 'uncategorized') === value,
},
{ title: '成本', key: 'totalCost', render: (row: any) => fmtCurrency(row.totalCost),
sorter: (a: any, b: any) => a.totalCost - b.totalCost },
{ title: '产出', key: 'totalRevenue', render: (row: any) => fmtCurrency(row.totalRevenue),
sorter: (a: any, b: any) => a.totalRevenue - b.totalRevenue },
{
title: 'ROI', key: 'roiValue',
render: (row: any) => h('span', { style: { color: roiColor(row.roiValue), fontWeight: 600 } }, fmtPercent(row.roiValue)),
sorter: (a: any, b: any) => (a.roiValue ?? -Infinity) - (b.roiValue ?? -Infinity),
sortOrder: 'descend' as const, // 默认按 ROI 降序
defaultSortOrder: 'descend' as const,
},
{ title: '置信度', key: 'confidence', render: (row: any) => h(ConfidenceBadge as any, { confidence: row.confidence, showLabel: false }) },
{
title: '操作', key: 'actions', render: (row: any) => h('a', {
style: { color: 'var(--color-primary-hex)', cursor: 'pointer' },
onClick: () => router.push(`/projects/${row.projectId}/roi`),
}, '查看 →'),
},
];
</script>
<template>
<div class="dashboard-page">
<NSpin :show="loading">
<!-- 3 ROI 大卡片 -->
<div class="kpi-grid">
<article class="kpi-card kpi-hero">
<div class="kpi-label">公司累计 ROI</div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }">
{{ fmtPercent(lifetimeData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="lifetimeData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(lifetimeData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(lifetimeData.summary.totalRevenue) }}</span></span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }">
{{ fmtPercent(mtdData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="mtdData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(mtdData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(mtdData.summary.totalRevenue) }}</span></span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本年 ROI<span class="kpi-suffix">YTD</span></div>
<div class="kpi-value-row">
<span class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }">
{{ fmtPercent(ytdData?.summary.roiValue) }}
</span>
</div>
<div class="kpi-meta" v-if="ytdData">
<span class="kpi-meta-row"><span class="kpi-meta-label">成本</span><span class="tabular-nums">{{ fmtCurrency(ytdData.summary.totalCost) }}</span></span>
<span class="kpi-meta-row"><span class="kpi-meta-label">产出</span><span class="tabular-nums">{{ fmtCurrency(ytdData.summary.totalRevenue) }}</span></span>
</div>
</article>
</div>
<!-- 切换时间窗口 -->
<div class="section-divider">
<div>
<div class="eyebrow">Business lines</div>
<h2 class="section-title">业务线分布</h2>
</div>
<NSelect v-model:value="windowKey" :options="[
{ label: '累计 (LTD)', value: 'lifetime' },
{ label: '本年 (YTD)', value: 'ytd' },
{ label: '本月 (MTD)', value: 'mtd' },
]" style="width:160px" size="small" />
</div>
<!-- 堆叠图 + 饼图 -->
<div class="charts-row">
<NCard size="small" title="各核心业务线 ROI 堆叠图">
<CategoryStackedBar v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
<NCard size="small" title="各业务线产出占比">
<RevenuePieChart v-if="activeData" :by-category="activeData.byCategory" />
<NEmpty v-else />
</NCard>
</div>
<!-- 项目明细表 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div class="table-header">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:600">项目明细</span>
<span class="result-count">显示 {{ filteredProjects.length }} / {{ activeData?.projects.length || 0 }} </span>
</div>
<div class="category-chips">
<button
v-for="(meta, k) in CATEGORY_META"
:key="k"
class="chip"
:class="{ active: selectedCategories.includes(k), disabled: (categoryStats[k]?.count || 0) === 0 }"
:style="selectedCategories.includes(k) ? { background: chipColor(meta.color), color: '#fff', borderColor: chipColor(meta.color) } : {}"
:disabled="(categoryStats[k]?.count || 0) === 0"
@click="toggleCategory(k)"
>
{{ meta.emoji }} {{ meta.label }}
<span class="chip-count">{{ categoryStats[k]?.count || 0 }}</span>
</button>
<NButton v-if="selectedCategories.length > 0" size="tiny" text type="primary" @click="selectedCategories = []">清空</NButton>
<span class="filter-divider">|</span>
<span class="filter-label">仅看有成本数据</span>
<NSwitch v-model:value="hideZeroCost" size="small" />
</div>
</div>
</template>
<NDataTable
v-if="activeData"
:columns="projectColumns"
:data="filteredProjects"
:max-height="500"
:row-key="(row: any) => row.projectId"
striped
/>
<NEmpty v-else />
</NCard>
</NSpin>
</div>
</template>
<style scoped>
.dashboard-page {
padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── Editorial 页头 ─────── */
.page-header {
margin-bottom: var(--space-10);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border-subtle);
}
.eyebrow {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
margin-bottom: var(--space-3);
}
.page-title {
font-family: var(--font-sans);
font-size: var(--text-display);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
margin: 0;
}
.page-lede {
margin-top: var(--space-3);
font-size: var(--text-md);
color: var(--color-text-secondary);
line-height: 1.5;
max-width: 56ch;
}
/* ─────── KPI 卡片 (Editorial Data Console) ─────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.kpi-card.kpi-hero {
grid-column: span 1;
}
@media (min-width: 1400px) {
.kpi-grid { grid-template-columns: 1.4fr 1fr 1fr; }
}
.kpi-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: var(--space-6) var(--space-7);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out);
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--color-bg-card) 0%, var(--color-bg-subtle) 100%);
opacity: 0;
transition: opacity var(--duration-base) var(--ease-out);
pointer-events: none;
}
.kpi-card:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.kpi-card.kpi-hero {
border-left: 3px solid var(--color-primary);
}
.kpi-label {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kpi-suffix {
font-size: 10px;
color: var(--color-text-muted);
padding: 1px 6px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
letter-spacing: 0.05em;
}
.kpi-value-row {
margin-top: var(--space-3);
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.kpi-value {
font-family: var(--font-mono);
font-size: var(--text-3xl);
font-weight: var(--weight-semibold);
letter-spacing: -0.025em;
line-height: 1;
}
.kpi-meta {
margin-top: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-subtle);
}
.kpi-meta-row {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.kpi-meta-label {
color: var(--color-text-muted);
}
/* ─────── Section 分隔 ─────── */
.section-divider {
margin-top: var(--space-12);
margin-bottom: var(--space-5);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
}
.section-title {
font-family: var(--font-sans);
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
margin: var(--space-2) 0 0 0;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
/* ─────── 项目明细表 ─────── */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: var(--space-4);
flex-wrap: wrap;
}
.category-chips {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--color-border);
background: var(--color-bg-card);
border-radius: var(--radius-full);
padding: 4px 12px;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-secondary);
transition: all var(--duration-fast) var(--ease-out);
}
.chip:hover:not(.disabled) {
border-color: var(--color-border-strong);
color: var(--color-text-primary);
background: var(--color-bg-subtle);
}
.chip.active { font-weight: var(--weight-semibold); box-shadow: var(--shadow-sm); }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--color-text-muted);
opacity: 1;
margin-left: 6px;
padding-left: 8px;
border-left: 1px solid currentColor;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.chip.active .chip-count {
color: oklch(1 0 0 / 0.7);
}
.result-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.filter-divider { color: var(--color-border); margin: 0 var(--space-1); user-select: none; }
.filter-label { font-size: var(--text-sm); color: var(--color-text-muted); }
/* 自适应:auto-fit + minmax 已经在窄屏自动单列, 这里仅微调 padding */
@media (max-width: 768px) {
.page-header { padding-bottom: var(--space-4); margin-bottom: var(--space-6); }
.section-divider { margin-top: var(--space-8); flex-wrap: wrap; }
}
</style>