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>
489 lines
17 KiB
Vue
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>
|