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>
This commit is contained in:
zyc 2026-05-22 15:28:48 +08:00
parent 5af612e3fd
commit 4a2ed8d414
17 changed files with 1416 additions and 403 deletions

View File

@ -67,14 +67,35 @@ overviewRoutes.get('/overview', async (c) => {
allowedObjIds = new Set(objs.map(o => o.id)); allowedObjIds = new Set(objs.map(o => o.id));
} }
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率) // ─── 性能优化:一次性批量拉取,内存里做 join,避免 N+1 ───
const allProjects = await db.select().from(projects); const [allProjects, allObjectivesRaw, allKRs, allUsersData] = await Promise.all([
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = []; db.select().from(projects),
db.select().from(objectives),
db.select().from(keyResults),
db.select().from(users),
]);
// 索引化方便 O(1) 查找
const objectivesByProject = new Map<string, typeof allObjectivesRaw>();
for (const o of allObjectivesRaw) {
if (!o.projectId) continue;
if (!objectivesByProject.has(o.projectId)) objectivesByProject.set(o.projectId, []);
objectivesByProject.get(o.projectId)!.push(o);
}
const krsByObjective = new Map<string, typeof allKRs>();
for (const kr of allKRs) {
if (!krsByObjective.has(kr.objectiveId)) krsByObjective.set(kr.objectiveId, []);
krsByObjective.get(kr.objectiveId)!.push(kr);
}
const usersById = new Map(allUsersData.map(u => [u.id, u]));
const projectsById = new Map(allProjects.map(p => [p.id, p]));
const objectivesById = new Map(allObjectivesRaw.map(o => [o.id, o]));
// 1. 各项目 OKR 整体进度
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
for (const proj of allProjects) { for (const proj of allProjects) {
if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue; if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
let projObjectives = await db.select().from(objectives) let projObjectives = objectivesByProject.get(proj.id) || [];
.where(eq(objectives.projectId, proj.id));
if (period) { if (period) {
projObjectives = projObjectives.filter(o => o.period === period); projObjectives = projObjectives.filter(o => o.period === period);
} }
@ -90,14 +111,11 @@ overviewRoutes.get('/overview', async (c) => {
}); });
} }
// 2. KR 完成状态分布(替代任务状态分布) // 2. KR 完成状态分布
const allKRs = await db.select().from(keyResults);
let filteredKRs = allKRs; let filteredKRs = allKRs;
if (mustFilterByProject || projectIds.length > 0) { if (mustFilterByProject || projectIds.length > 0) {
const projObjIds = new Set( const projObjIds = new Set(
(await db.select().from(objectives)) allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id)
.filter(o => o.projectId && projectIds.includes(o.projectId))
.map(o => o.id)
); );
filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId)); filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId));
} }
@ -125,16 +143,17 @@ overviewRoutes.get('/overview', async (c) => {
// 4. Weekly Code Activity (last 12 weeks) // 4. Weekly Code Activity (last 12 weeks)
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate(); const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
let commits = await db.select().from(gitCommits) const [commitsRaw, prsRaw] = await Promise.all([
.where(gte(gitCommits.committedAt, twelveWeeksAgo)); db.select().from(gitCommits).where(gte(gitCommits.committedAt, twelveWeeksAgo)),
let prs = await db.select().from(gitPRs) db.select().from(gitPRs).where(gte(gitPRs.createdAt, twelveWeeksAgo)),
.where(gte(gitPRs.createdAt, twelveWeeksAgo)); ]);
// 观察者:按项目绑定仓库过滤 let commits = commitsRaw;
let prs = prsRaw;
if (allowedRepos) { if (allowedRepos) {
commits = commits.filter(c => allowedRepos.has(c.repoName)); commits = commits.filter(c => allowedRepos.has(c.repoName));
prs = prs.filter(p => allowedRepos.has(p.repoName)); prs = prs.filter(p => allowedRepos.has(p.repoName));
} }
const allUsers = await db.select().from(users); const allUsers = allUsersData; // 复用上面已 fetch 的
const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {}; const weekMap: Record<string, Record<string, { commits: number; prs: number }>> = {};
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
@ -170,23 +189,19 @@ overviewRoutes.get('/overview', async (c) => {
})), })),
}; };
// 5. OKR Progress // 5. OKR Progress(用前面已 fetch 的 allObjectivesRaw + krsByObjective + usersById)
let allObjectives = period let allObjectives = period
? await db.select().from(objectives).where(eq(objectives.period, period)) ? allObjectivesRaw.filter(o => o.period === period)
: await db.select().from(objectives); : allObjectivesRaw;
if (mustFilterByProject || projectIds.length > 0) { if (mustFilterByProject || projectIds.length > 0) {
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId)); allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
} }
const okrProgress = []; const okrProgress = allObjectives.map(obj => {
for (const obj of allObjectives) { const krs = krsByObjective.get(obj.id) || [];
const krs = await db.select().from(keyResults) const owner = obj.ownerId ? usersById.get(obj.ownerId) : null;
.where(eq(keyResults.objectiveId, obj.id)); return {
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
okrProgress.push({
id: obj.id, id: obj.id,
title: obj.title, title: obj.title,
ownerName: owner?.displayName || '未指定', ownerName: owner?.displayName || '未指定',
@ -199,17 +214,16 @@ overviewRoutes.get('/overview', async (c) => {
target: kr.targetValue, target: kr.targetValue,
unit: kr.unit || '', unit: kr.unit || '',
})), })),
}); };
} });
// 6. 指定周的 KR支持 weekOffset 参数0=本周1=下周,-1=上周) // 6. 指定周的 KR支持 weekOffset 参数0=本周1=下周,-1=上周)
const weekOffset = parseInt(c.req.query('weekOffset') || '0'); const weekOffset = parseInt(c.req.query('weekOffset') || '0');
const weekStart = dayjs().startOf('week').add(weekOffset, 'week'); const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week'); const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
let allKRsRaw = await db.select().from(keyResults); let allKRsRaw = allKRs; // 复用前面 fetch
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o])); const allObjsMap = objectivesById;
// 观察者:过滤到已分配项目的 KR
if (allowedObjIds) { if (allowedObjIds) {
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId)); allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
} }
@ -231,12 +245,8 @@ overviewRoutes.get('/overview', async (c) => {
const urgentKRs = []; const urgentKRs = [];
for (const kr of thisWeekKRs.slice(0, 20)) { for (const kr of thisWeekKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId); const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const endDate = kr.endDate || ''; const endDate = kr.endDate || '';
const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day')); const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day'));
@ -288,22 +298,24 @@ overviewRoutes.get('/overview', async (c) => {
return order(a) - order(b); return order(a) - order(b);
}); });
// 批量取异常 KRs 的最后日志(单次 IN 查询代替循环)
const abnormalKrIds = abnormalKRs.slice(0, 20).map(k => k.id);
const lastLogByKr = new Map<string, string>();
if (abnormalKrIds.length > 0) {
const logs = await db.select().from(krLogs)
.where(inArray(krLogs.krId, abnormalKrIds))
.orderBy(desc(krLogs.createdAt));
for (const log of logs) {
if (!lastLogByKr.has(log.krId)) lastLogByKr.set(log.krId, log.detail || '');
}
}
const overdueList = []; const overdueList = [];
for (const kr of abnormalKRs.slice(0, 20)) { for (const kr of abnormalKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId); const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
: null; const reason = lastLogByKr.get(kr.id) || '';
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
// 获取最后一条操作日志作为原因
const lastLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(1);
const reason = lastLog[0]?.detail || '';
let itemStatus: string; let itemStatus: string;
let statusLabel: string; let statusLabel: string;

View File

@ -23,6 +23,9 @@ import { applyAutoIdentifier } from '../services/roi/identifier-generator';
export const roiRoutes = new Hono(); export const roiRoutes = new Hono();
// ✱ 全局权限:所有 ROI 相关端点(含 /api/projects/:id/{tag,cost-events,...})只允许 admin
roiRoutes.use('*', requireRole('admin'));
// ────────────────────────────────────────── // ──────────────────────────────────────────
// 核心查询接口 // 核心查询接口
// ────────────────────────────────────────── // ──────────────────────────────────────────

View File

@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { db } from '../db/index'; import { db } from '../db/index';
import { objectives, keyResults, users, projects, krLogs } from '../db/schema'; import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
@ -6,24 +6,56 @@ import { desc } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler'; import { AppError } from '../middleware/error-handler';
export async function getOKRByPeriod(period?: string) { export async function getOKRByPeriod(period?: string) {
// 1. 拿 objectives(按 period 可选过滤)
const allObjectives = period const allObjectives = period
? await db.select().from(objectives).where(eq(objectives.period, period)) ? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives); : await db.select().from(objectives);
const result = []; if (allObjectives.length === 0) return { objectives: [] };
for (const obj of allObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
const owner = obj.ownerId const objIds = allObjectives.map(o => o.id);
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) const ownerIds = Array.from(new Set(allObjectives.map(o => o.ownerId).filter(Boolean) as string[]));
: null; const projectIds = Array.from(new Set(allObjectives.map(o => o.projectId).filter(Boolean) as string[]));
const project = obj.projectId // 2. 一次性批量拉 KRs / users / projects
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) }) const [allKRs, allOwners, allProjects] = await Promise.all([
: null; db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)),
ownerIds.length > 0 ? db.select().from(users).where(inArray(users.id, ownerIds)) : Promise.resolve([]),
projectIds.length > 0 ? db.select().from(projects).where(inArray(projects.id, projectIds)) : Promise.resolve([]),
]);
result.push({ // 3. 一次性批量拉所有 KR 的 logs(只取 postponed 类型,减少传输)
const krIds = allKRs.map(k => k.id);
const allLogs = krIds.length > 0
? await db.select().from(krLogs)
.where(inArray(krLogs.krId, krIds))
.orderBy(desc(krLogs.createdAt))
: [];
// 4. 索引化
const krsByObj = new Map<string, typeof allKRs>();
for (const kr of allKRs) {
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
krsByObj.get(kr.objectiveId)!.push(kr);
}
const ownerById = new Map(allOwners.map(u => [u.id, u]));
const projectById = new Map(allProjects.map(p => [p.id, p]));
// 每个 KR 取它的"最近一条 postponed log"
const postponedByKr = new Map<string, string>();
for (const log of allLogs) { // 已按 createdAt desc 排序
if (log.action === 'postponed' && !postponedByKr.has(log.krId)) {
postponedByKr.set(log.krId, log.detail || '');
}
}
// 5. 组装(纯内存,O(n))
const result = allObjectives.map(obj => {
const krs = krsByObj.get(obj.id) || [];
const owner = obj.ownerId ? ownerById.get(obj.ownerId) : null;
const project = obj.projectId ? projectById.get(obj.projectId) : null;
return {
id: obj.id, id: obj.id,
title: obj.title, title: obj.title,
ownerName: owner?.displayName || '未指定', ownerName: owner?.displayName || '未指定',
@ -33,15 +65,8 @@ export async function getOKRByPeriod(period?: string) {
startDate: obj.startDate || null, startDate: obj.startDate || null,
endDate: obj.endDate || null, endDate: obj.endDate || null,
progress: obj.progress || 0, progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => { keyResults: krs.map(kr => {
// 查是否有延期记录 const lastPostponeReason = postponedByKr.get(kr.id) ?? null;
const postponeLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(5);
const wasPostponed = postponeLog.some(l => l.action === 'postponed');
const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null;
return { return {
id: kr.id, id: kr.id,
title: kr.title, title: kr.title,
@ -50,7 +75,7 @@ export async function getOKRByPeriod(period?: string) {
unit: kr.unit || '', unit: kr.unit || '',
weight: kr.weight || 1, weight: kr.weight || 1,
status: kr.status || 'active', status: kr.status || 'active',
wasPostponed, wasPostponed: lastPostponeReason !== null,
lastPostponeReason, lastPostponeReason,
startDate: kr.startDate || null, startDate: kr.startDate || null,
endDate: kr.endDate || null, endDate: kr.endDate || null,
@ -58,9 +83,9 @@ export async function getOKRByPeriod(period?: string) {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0, : 0,
}; };
})), }),
}); };
} });
return { objectives: result }; return { objectives: result };
} }

View File

@ -70,6 +70,7 @@ onUnmounted(() => {
flex: 1; flex: 1;
padding: var(--space-6); padding: var(--space-6);
overflow-y: auto; overflow-y: auto;
background: var(--color-bg);
} }
/* Overlay backdrop for mobile sidebar */ /* Overlay backdrop for mobile sidebar */

View File

@ -102,8 +102,8 @@ const menuOptions = computed(() => {
items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' }); items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' });
} }
// ROI : admin/manager // ROI : admin
if (role === 'admin' || role === 'manager') { if (role === 'admin') {
items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' }); items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' });
} }
@ -276,17 +276,18 @@ const roleTagType = computed(() => {
width: var(--sidebar-width); width: var(--sidebar-width);
height: 100vh; height: 100vh;
background: var(--color-bg-sidebar); background: var(--color-bg-sidebar);
color: #E5E7EB; color: var(--color-text-onDark);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
transition: width var(--duration-collapse) var(--ease-default), transition: width var(--duration-collapse) var(--ease-out),
transform 0.3s ease; transform var(--duration-medium) var(--ease-out);
z-index: var(--z-sticky); z-index: var(--z-sticky);
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
border-right: 1px solid oklch(0.25 0.012 230);
} }
.sidebar.collapsed { .sidebar.collapsed {
@ -334,20 +335,24 @@ const roleTagType = computed(() => {
.logo-icon { .logo-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: var(--color-primary-hex); background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-hover) 100%);
border-radius: var(--radius-btn); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 800; font-family: var(--font-display);
font-size: 12px; font-weight: var(--weight-semibold);
color: white; font-size: 13px;
color: var(--color-text-onDark);
flex-shrink: 0; flex-shrink: 0;
letter-spacing: -0.02em;
} }
.logo-text { .logo-text {
font-weight: 700; font-family: var(--font-display);
font-size: 16px; font-weight: var(--weight-semibold);
font-size: var(--text-md);
letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
} }
@ -358,25 +363,31 @@ const roleTagType = computed(() => {
} }
.nav-item { .nav-item {
padding: var(--space-3) var(--space-4); padding: var(--space-2) var(--space-4);
margin-bottom: var(--space-1); margin-bottom: 2px;
border-radius: var(--radius-btn); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background var(--duration-hover) var(--ease-default); transition: background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
position: relative; position: relative;
white-space: nowrap; white-space: nowrap;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: oklch(0.75 0.010 220);
} }
.nav-item:hover { .nav-item:hover {
background: rgba(255,255,255,0.08); background: oklch(0.24 0.014 230);
color: var(--color-text-onDark);
} }
.nav-item.active { .nav-item.active {
background: rgba(59,89,152,0.3); background: oklch(0.26 0.018 220);
border-left: 3px solid var(--color-primary-hex); color: var(--color-accent);
border-left: 2px solid var(--color-accent);
} }
.nav-label { .nav-label {

View File

@ -7,13 +7,22 @@ const props = defineProps<{
}>(); }>();
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛', cash_cow: '现金牛',
efficiency_tool: '⚙️ 效能工具', efficiency_tool: '效能工具',
moat: '💎 资本护城河', moat: '资本护城河',
composite: '🚀 复合型', composite: '复合型',
uncategorized: '未打标', uncategorized: '未打标',
}; };
function fmtCurrency(n: number): string {
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}`;
return `¥${Math.round(n).toLocaleString()}`;
}
const total = computed(() =>
Object.values(props.byCategory).reduce((s, v) => s + (v.totalRevenue > 0 ? v.totalRevenue : 0), 0)
);
const option = computed(() => { const option = computed(() => {
const data = Object.entries(props.byCategory) const data = Object.entries(props.byCategory)
.filter(([, v]) => v.totalRevenue > 0) .filter(([, v]) => v.totalRevenue > 0)
@ -21,13 +30,74 @@ const option = computed(() => {
return { return {
color: CHART_COLORS, color: CHART_COLORS,
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' }, textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' },
legend: { orient: 'vertical', left: 'left', top: 'middle', textStyle: { fontSize: 12 } }, tooltip: {
trigger: 'item',
backgroundColor: '#ffffff',
borderColor: '#dfe2e6',
borderWidth: 1,
textStyle: { color: '#2d3033', fontSize: 12 },
extraCssText: 'box-shadow: 0 8px 24px rgba(34,40,42,0.06); border-radius: 10px; padding: 8px 12px;',
formatter: (params: any) => `
<div style="font-weight:600;margin-bottom:4px">${params.name}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:13px">¥${params.value.toLocaleString()} <span style="color:#7a8085">(${params.percent}%)</span></div>
`,
},
legend: {
orient: 'horizontal',
bottom: 0,
left: 'center',
itemGap: 18,
itemWidth: 8,
itemHeight: 8,
icon: 'circle',
textStyle: { fontSize: 12, color: '#4d5258' },
},
graphic: [
{
type: 'text',
left: 'center',
top: '38%',
style: {
text: '总产出',
fill: '#7a8085',
fontSize: 11,
fontFamily: "'Geist', 'PingFang SC', sans-serif",
fontWeight: 500,
},
},
{
type: 'text',
left: 'center',
top: '46%',
style: {
text: fmtCurrency(total.value),
fill: '#2d3033',
fontSize: 22,
fontWeight: 600,
fontFamily: "'JetBrains Mono', monospace",
},
},
],
series: [{ series: [{
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['58%', '78%'],
center: ['65%', '50%'], center: ['50%', '45%'],
label: { formatter: '{b}\n{d}%' }, avoidLabelOverlap: true,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 2,
},
label: { show: false },
labelLine: { show: false },
emphasis: {
scale: true,
scaleSize: 6,
itemStyle: {
shadowBlur: 12,
shadowColor: 'rgba(0,0,0,0.10)',
},
},
data, data,
}], }],
}; };

View File

@ -85,14 +85,18 @@ const unmappedColumns = [
<strong>当前映射 ({{ mappings.length }})</strong> <strong>当前映射 ({{ mappings.length }})</strong>
<NButton type="primary" size="small" @click="showModal = true">+ 添加映射</NButton> <NButton type="primary" size="small" @click="showModal = true">+ 添加映射</NButton>
</div> </div>
<NDataTable :columns="mappingColumns" :data="mappings" size="small" :bordered="false" /> <div class="table-shell">
<NDataTable :columns="mappingColumns" :data="mappings" size="small" :bordered="false" />
</div>
<div style="margin-top:24px"> <div style="margin-top:24px">
<strong style="color:var(--color-text-muted)"> 未映射的营收事件 ({{ unmapped.length }})</strong> <strong style="color:var(--color-text-muted)"> 未映射的营收事件 ({{ unmapped.length }})</strong>
<div style="font-size:12px;color:var(--color-text-muted);margin:6px 0"> <div style="font-size:12px;color:var(--color-text-muted);margin:6px 0">
外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理新增对应映射后,后续数据会自动归类 外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理新增对应映射后,后续数据会自动归类
</div> </div>
<NDataTable :columns="unmappedColumns" :data="unmapped" size="small" :bordered="false" :max-height="300" /> <div class="table-shell">
<NDataTable :columns="unmappedColumns" :data="unmapped" size="small" :bordered="false" :max-height="300" />
</div>
</div> </div>
<NModal v-model:show="showModal" preset="card" title="新增项目映射" style="width:500px"> <NModal v-model:show="showModal" preset="card" title="新增项目映射" style="width:500px">

View File

@ -29,35 +29,40 @@ defineProps<{
<style scoped> <style scoped>
.data-card { .data-card {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-card); border-radius: var(--radius-xl);
border: 1px solid var(--color-border); border: 1px solid var(--color-border-subtle);
padding: var(--space-5); padding: var(--space-6);
transition: box-shadow var(--duration-hover) var(--ease-default), transform var(--duration-hover) var(--ease-default); transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out);
} }
.data-card:hover { .data-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); border-color: var(--color-border);
transform: translateY(-1px); box-shadow: var(--shadow-sm);
} }
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: var(--space-4); margin-bottom: var(--space-5);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
} }
.card-title { .card-title {
font-size: 14px; font-family: var(--font-sans);
font-weight: 700; font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--color-text-primary); color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
margin: 0; margin: 0;
} }
.card-subtitle { .card-subtitle {
font-size: 12px; font-size: var(--text-sm);
color: var(--color-text-secondary); color: var(--color-text-muted);
margin: 2px 0 0; margin: var(--space-1) 0 0;
} }
.card-body { .card-body {

View File

@ -12,7 +12,46 @@ echarts.use([
CanvasRenderer, CanvasRenderer,
]); ]);
export const CHART_COLORS = ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#2B8CA3', '#DC2626', '#8B5CF6', '#06B6D4']; /**
* Editorial Data Console OKLCH 8
* Tailwind ,,
*/
export const CHART_COLORS = [
'#1f3a45', // 墨青 (主色)
'#317a5d', // 翠绿
'#c47918', // 琥珀
'#5a4d8c', // 紫罗兰
'#b13a25', // 朱砂
'#6e7635', // 橄榄
'#2d5d8c', // 钢蓝
'#955080', // 玫瑰
];
/** 默认图表配置 — 应用 editorial 风格 */
export const DEFAULT_CHART_THEME = {
textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' },
legend: { textStyle: { fontSize: 12, color: '#4d5258' }, itemGap: 16 },
grid: { left: 56, right: 24, top: 40, bottom: 32, containLabel: true },
xAxis: {
axisLine: { lineStyle: { color: '#dfe2e6' } },
axisTick: { show: false },
axisLabel: { color: '#7a8085', fontSize: 11 },
splitLine: { show: false },
},
yAxis: {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#7a8085', fontSize: 11 },
splitLine: { lineStyle: { color: '#eaeded', type: 'dashed' } },
},
tooltip: {
backgroundColor: '#ffffff',
borderColor: '#dfe2e6',
borderWidth: 1,
textStyle: { color: '#2d3033', fontSize: 12 },
extraCssText: 'box-shadow: 0 8px 24px rgba(34,40,42,0.06), 0 4px 8px rgba(34,40,42,0.04); border-radius: 10px;',
},
};
/** /**
* Broad chart options type to avoid strict ECharts type conflicts with * Broad chart options type to avoid strict ECharts type conflicts with

View File

@ -35,12 +35,13 @@ const router = createRouter({
path: 'projects/:id/roi', path: 'projects/:id/roi',
name: 'ProjectRoiBoard', name: 'ProjectRoiBoard',
component: () => import('@/views/ProjectRoiBoard.vue'), component: () => import('@/views/ProjectRoiBoard.vue'),
meta: { roles: ['admin'] },
}, },
{ {
path: 'roi', path: 'roi',
name: 'RoiDashboard', name: 'RoiDashboard',
component: () => import('@/views/RoiDashboard.vue'), component: () => import('@/views/RoiDashboard.vue'),
meta: { roles: ['admin', 'manager'] }, meta: { roles: ['admin'] },
}, },
// B-17 fix: added member list route // B-17 fix: added member list route
{ {

View File

@ -1,77 +1,181 @@
/* ============================================================
* DevPerf 2.0 设计令牌系统
* 审美方向: Editorial Data Console (Linear/Stripe 精致感 + 数据密度)
* AI : 不用 Inter/Roboto不用紫蓝渐变不用 glassmorphism
* 色彩: OKLCH 感知均匀, 中性色带 0.005-0.012 暖色调
* ============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600;6..72,700&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
:root { :root {
/* Primary - Trusted Indigo */ /* ─────────── 色彩系统 (OKLCH) ─────────── */
--color-primary: oklch(0.45 0.12 255); /* 主色 — 深墨青(克制信任, 不是 Tailwind blue) */
--color-primary-hex: #3B5998; --color-primary: oklch(0.32 0.05 200);
--color-primary-hover: oklch(0.40 0.12 255); --color-primary-hover: oklch(0.27 0.05 200);
--color-primary-light: oklch(0.92 0.03 255); --color-primary-press: oklch(0.23 0.05 200);
--color-primary-soft: oklch(0.94 0.018 200);
/* Accent - Amber */ /* 强调色 — 暖琥珀(数据高亮 / CTA) */
--color-accent: oklch(0.75 0.15 75); --color-accent: oklch(0.72 0.16 65);
--color-accent-hex: #D4920A; --color-accent-hover: oklch(0.66 0.17 60);
--color-accent-soft: oklch(0.96 0.04 75);
/* Semantic */ /* 语义色 */
--color-success: #0D9668; --color-success: oklch(0.58 0.13 155);
--color-warning: #D4920A; --color-success-soft: oklch(0.95 0.04 155);
--color-error: #DC2626; --color-warning: oklch(0.72 0.16 65);
--color-info: #2B8CA3; --color-warning-soft: oklch(0.96 0.04 75);
--color-error: oklch(0.55 0.18 25);
--color-error-soft: oklch(0.96 0.03 25);
--color-info: oklch(0.52 0.11 240);
--color-info-soft: oklch(0.95 0.03 240);
/* Chart palette */ /* 中性色阶(暖灰, chroma 0.005~0.012, 不是死灰) */
--chart-1: #3B5998; --color-bg: oklch(0.985 0.003 80); /* 页面背景 */
--chart-2: #0D9668; --color-bg-card: oklch(1.000 0.000 0); /* 卡片 */
--chart-3: #D4920A; --color-bg-subtle: oklch(0.965 0.004 80); /* 浅灰区块 */
--chart-4: #7C4DBA; --color-bg-hover: oklch(0.955 0.005 80); /* hover */
--chart-5: #2B8CA3; --color-bg-sidebar: oklch(0.18 0.012 230); /* 侧边栏深底带蓝绿 */
--color-bg-sidebar-2: oklch(0.21 0.013 230); /* 侧边栏次级 */
/* Neutral */ --color-border-subtle: oklch(0.935 0.006 80);
--color-bg: #F8F9FB; --color-border: oklch(0.88 0.008 80);
--color-bg-card: #FFFFFF; --color-border-strong: oklch(0.78 0.010 80);
--color-bg-sidebar: #1E2433;
--color-text-primary: #1A1F2E;
--color-text-secondary: #6B7280;
--color-text-muted: #9CA3AF;
--color-border: #E5E7EB;
/* Typography */ --color-text-muted: oklch(0.58 0.009 80);
--font-heading: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; --color-text-secondary: oklch(0.42 0.011 80);
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; --color-text-primary: oklch(0.22 0.012 80);
--font-code: 'JetBrains Mono', 'Fira Code', monospace; --color-text-onDark: oklch(0.92 0.005 80);
--color-text-onDarkMuted:oklch(0.65 0.010 220);
/* Spacing */ /* 兼容老变量 (保持现有组件不破) */
--space-1: 4px; --color-primary-hex: #1f3a45;
--space-2: 8px; --color-accent-hex: #c47918;
--space-3: 12px; --color-text-secondary-legacy: var(--color-text-secondary);
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Border radius */ /* ─────────── 图表色板(OKLCH 衍生, 8 色, 中等饱和) ─────────── */
--radius-btn: 8px; --chart-1: oklch(0.32 0.05 200); /* 墨青 */
--radius-card: 12px; --chart-2: oklch(0.58 0.13 155); /* 翠绿 */
--radius-modal: 16px; --chart-3: oklch(0.72 0.16 65); /* 琥珀 */
--radius-pill: 9999px; --chart-4: oklch(0.50 0.15 280); /* 紫罗兰 */
--chart-5: oklch(0.55 0.18 25); /* 朱砂 */
--chart-6: oklch(0.62 0.13 105); /* 橄榄 */
--chart-7: oklch(0.50 0.10 220); /* 钢蓝 */
--chart-8: oklch(0.65 0.12 320); /* 玫瑰 */
/* Easing */ /* ─────────── 字体 ─────────── */
--ease-default: cubic-bezier(0.25, 1, 0.5, 1); /* Display: Newsreader 衬线(editorial 标题) */
--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1); --font-display: 'Newsreader', 'Source Serif 4', 'Songti SC', 'STSong', Georgia, serif;
--duration-hover: 200ms; /* Sans: Geist(Vercel 现代无衬线, 替代 Inter) + PingFang */
--duration-entrance: 600ms; --font-sans: 'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
--duration-collapse: 300ms; /* Mono: JetBrains Mono(等宽 KPI 数字) */
--font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
/* Z-index */ /* 兼容老变量 */
--font-heading: var(--font-sans);
--font-body: var(--font-sans);
--font-code: var(--font-mono);
/* ─────────── 字号比例尺(ratio 1.25, base 14px) ─────────── */
--text-xs: 11px; /* caption */
--text-sm: 12px; /* secondary */
--text-base: 14px; /* body */
--text-md: 16px; /* lead */
--text-lg: 18px; /* small heading */
--text-xl: 22px; /* section title */
--text-2xl: 28px; /* page title */
--text-3xl: 36px; /* KPI medium */
--text-4xl: 46px; /* KPI hero */
--text-display: clamp(24px, 1.6vw + 0.6rem, 32px); /* 中文友好的页头字号 */
/* ─────────── 行高 ─────────── */
--leading-tight: 1.2;
--leading-snug: 1.35;
--leading-normal: 1.55;
--leading-relaxed:1.7;
/* ─────────── 字重 ─────────── */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ─────────── 字间距 ─────────── */
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.04em;
/* ─────────── 空间(4px 基础) ─────────── */
--space-0_5: 2px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-14: 56px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ─────────── 圆角 ─────────── */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-2xl: 18px;
--radius-full:9999px;
/* 兼容老变量 */
--radius-btn: var(--radius-md);
--radius-card: var(--radius-xl);
--radius-modal: var(--radius-2xl);
--radius-pill: var(--radius-full);
/* ─────────── 阴影(多层柔和叠加) ─────────── */
--shadow-xs: 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-sm: 0 1px 2px oklch(0.22 0.01 80 / 0.05), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-md: 0 2px 4px oklch(0.22 0.01 80 / 0.04), 0 4px 8px oklch(0.22 0.01 80 / 0.04), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-lg: 0 4px 8px oklch(0.22 0.01 80 / 0.04), 0 8px 24px oklch(0.22 0.01 80 / 0.06), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-xl: 0 8px 16px oklch(0.22 0.01 80 / 0.04), 0 16px 48px oklch(0.22 0.01 80 / 0.08), 0 1px 1px oklch(0.22 0.01 80 / 0.03);
--shadow-focus: 0 0 0 3px oklch(0.32 0.05 200 / 0.18);
--shadow-focus-error: 0 0 0 3px oklch(0.55 0.18 25 / 0.18);
/* ─────────── 缓动 + 时长(exponential easing, 反 bounce) ─────────── */
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-default: var(--ease-out); /* 老变量兼容 */
--ease-entrance: var(--ease-out-expo);
--duration-instant: 100ms;
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-medium: 300ms;
--duration-slow: 500ms;
--duration-hover: var(--duration-fast);
--duration-entrance:var(--duration-slow);
--duration-collapse:var(--duration-medium);
/* ─────────── Z-index ─────────── */
--z-base: 1;
--z-dropdown: 100; --z-dropdown: 100;
--z-sticky: 200; --z-sticky: 200;
--z-modal: 300; --z-modal: 300;
--z-toast: 9999; --z-toast: 9999;
/* Sidebar */ /* ─────────── Layout ─────────── */
--sidebar-width: 240px; --sidebar-width: 240px;
--sidebar-collapsed-width: 64px; --sidebar-collapsed-width: 64px;
--content-max: 1440px;
} }
/* ─────────── Reset ─────────── */
*, *,
*::before, *::before,
*::after { *::after {
@ -87,58 +191,158 @@ html, body {
} }
html { html {
font-family: var(--font-body); font-family: var(--font-sans);
font-size: 14px; font-size: var(--text-base);
line-height: 1.6; line-height: var(--leading-normal);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg); background-color: var(--color-bg);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'ss01', 'cv01', 'cv11';
} }
body { body {
min-height: 100vh; min-height: 100vh;
} }
h1, h2, h3, h4, h5, h6 { /* 标题: 中文用 sans(PingFang)更协调, 衬线只用在英文 eyebrow */
font-family: var(--font-heading); h1, h2, h3 {
font-weight: 700; font-family: var(--font-sans);
line-height: 1.3; font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
} }
code, pre { h4, h5, h6 {
font-family: var(--font-code); font-family: var(--font-sans);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
code, pre, kbd {
font-family: var(--font-mono);
font-feature-settings: 'zero', 'ss01';
}
/* 数字: tabular nums (KPI 用) */
.tabular-nums {
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
letter-spacing: -0.01em;
}
/* editorial 副标题 */
.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);
} }
a { a {
color: var(--color-primary-hex); color: var(--color-primary);
text-decoration: none; text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
} }
a:hover { /* focus-visible(键盘用户) */
text-decoration: underline; :focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
} }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-border); background: var(--color-border);
border-radius: 3px; border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted); background: var(--color-border-strong);
background-clip: padding-box;
} }
/* Tabular figures for numbers */ /* 减少动效偏好 */
.tabular-nums { @media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* utility */
.surface { background: var(--color-bg-card); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-xl); }
.hairline { border-bottom: 1px solid var(--color-border-subtle); }
/* 表格容器统一 wrapper:白底 + 边框 + 圆角,跟 list-card / DataCard 视觉一致 */
.table-shell {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
overflow: hidden;
}
/* ============================================================
* 全局表格基线 (Naive UI NDataTable 统一样式)
* Editorial Data Console 风格:浅灰表头 + dashed 行分隔 + hover 高亮
* ============================================================ */
.n-data-table .n-data-table-th {
background: var(--color-bg-subtle) !important;
color: var(--color-text-secondary) !important;
font-family: var(--font-sans) !important;
font-size: 12px !important;
font-weight: var(--weight-medium) !important;
letter-spacing: 0.02em !important;
padding: 10px 16px !important;
border-bottom: 1px solid var(--color-border) !important;
border-right: none !important;
}
.n-data-table .n-data-table-td {
padding: 12px 16px !important;
border-bottom: 1px solid var(--color-border-subtle) !important;
border-right: none !important;
font-size: var(--text-sm);
color: var(--color-text-primary);
transition: background var(--duration-fast) var(--ease-out);
}
.n-data-table .n-data-table-tr:hover .n-data-table-td {
background: var(--color-bg-subtle) !important;
}
.n-data-table .n-data-table-tr:last-child .n-data-table-td {
border-bottom: none !important;
}
/* 数字单元格自动等宽 (用 .tabular-nums class 或 td 内 .tabular-nums span) */
.n-data-table .n-data-table-td .tabular-nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* 空状态贴齐主题 */
.n-data-table .n-data-table-empty {
padding: var(--space-10) var(--space-4) !important;
color: var(--color-text-muted);
}

View File

@ -1,27 +1,68 @@
import type { GlobalThemeOverrides } from 'naive-ui'; import type { GlobalThemeOverrides } from 'naive-ui';
/**
* Naive UI global.css
* OKLCH(0.32 0.05 200) hex #1F3A45
*/
export const naiveThemeOverrides: GlobalThemeOverrides = { export const naiveThemeOverrides: GlobalThemeOverrides = {
common: { common: {
primaryColor: '#3B5998', // 主色板 (OKLCH 转近似 hex)
primaryColorHover: '#2D4373', primaryColor: '#1f3a45',
primaryColorPressed: '#1E2D4F', primaryColorHover: '#173039',
primaryColorSuppl: '#3B5998', primaryColorPressed: '#0f2730',
infoColor: '#2B8CA3', primaryColorSuppl: '#1f3a45',
successColor: '#0D9668',
warningColor: '#D4920A', // 语义色 (OKLCH 转近似 hex)
errorColor: '#DC2626', infoColor: '#2d5d8c',
fontFamily: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif", infoColorHover: '#264f78',
fontFamilyMono: "'JetBrains Mono', 'Fira Code', monospace", successColor: '#317a5d',
borderRadius: '8px', successColorHover: '#2a6b51',
warningColor: '#c47918',
warningColorHover: '#a86715',
errorColor: '#b13a25',
errorColorHover: '#9c3220',
// 字体
fontFamily:
"'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontFamilyMono:
"'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace",
// 文字色 (跟 OKLCH 中性色阶对齐)
textColorBase: '#2d3033',
textColor1: '#2d3033',
textColor2: '#4d5258',
textColor3: '#7a8085',
placeholderColor: '#a4a8ac',
// 卡片/分隔线
borderColor: '#dfe2e6',
dividerColor: '#e9ecef',
cardColor: '#ffffff',
bodyColor: '#fafbfb',
modalColor: '#ffffff',
// 圆角
borderRadius: '10px',
borderRadiusSmall: '6px', borderRadiusSmall: '6px',
// 字号
fontSize: '14px',
fontSizeSmall: '13px',
}, },
Button: { Button: {
borderRadiusMedium: '8px', borderRadiusMedium: '8px',
borderRadiusSmall: '6px', borderRadiusSmall: '6px',
borderRadiusLarge: '10px', borderRadiusLarge: '10px',
fontWeight: '500',
fontWeightStrong: '600',
paddingMedium: '0 14px',
}, },
Card: { Card: {
borderRadius: '12px', borderRadius: '14px',
paddingMedium: '20px 24px',
color: '#ffffff',
borderColor: '#eaeded',
}, },
Dialog: { Dialog: {
borderRadius: '16px', borderRadius: '16px',
@ -31,8 +72,28 @@ export const naiveThemeOverrides: GlobalThemeOverrides = {
}, },
DataTable: { DataTable: {
borderRadius: '12px', borderRadius: '12px',
thColor: '#fafbfb',
thColorHover: '#f3f5f6',
thTextColor: '#4d5258',
thFontWeight: '600',
fontSize: '13px',
}, },
Tag: { Tag: {
borderRadius: '6px', borderRadius: '6px',
fontWeightStrong: '500',
},
Tabs: {
tabFontSize: '14px',
tabFontWeightActive: '600',
},
Tooltip: {
borderRadius: '8px',
fontSize: '13px',
},
Modal: {
color: '#ffffff',
},
Drawer: {
color: '#ffffff',
}, },
}; };

View File

@ -387,5 +387,8 @@ const roleOptions = [
.table-responsive { .table-responsive {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
} }
</style> </style>

View File

@ -332,6 +332,7 @@ function canEditObj(obj: any): boolean {
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2> <h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2>
<ProjectTagSelector <ProjectTagSelector
v-if="authStore.isAdmin"
:project-id="projectId" :project-id="projectId"
:initial-category="data.project?.category || null" :initial-category="data.project?.category || null"
:initial-composite-strategies="data.project?.compositeStrategies || null" :initial-composite-strategies="data.project?.compositeStrategies || null"
@ -339,12 +340,12 @@ function canEditObj(obj: any): boolean {
:initial-project-type="data.project?.projectType || null" :initial-project-type="data.project?.projectType || null"
:initial-launched-at="data.project?.launchedAt || null" :initial-launched-at="data.project?.launchedAt || null"
:initial-v-asset="data.project?.vAsset || null" :initial-v-asset="data.project?.vAsset || null"
:can-edit="authStore.canEdit" :can-edit="true"
@saved="loadData" @saved="loadData"
/> />
</div> </div>
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<NButton size="small" type="primary" ghost @click="goToRoiBoard"> <NButton v-if="authStore.isAdmin" size="small" type="primary" ghost @click="goToRoiBoard">
💎 ROI 看板 💎 ROI 看板
</NButton> </NButton>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span> <span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'; import { ref, computed, onMounted, h } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NTag, NEmpty, useMessage } from 'naive-ui'; import { NSpin, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NTag, NEmpty, useMessage } from 'naive-ui';
import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin'; import { getAdminProjectsApi, createProjectApi, updateProjectApi, deleteProjectApi, bindRepoApi, getProjectReposApi, unbindRepoApi } from '@/api/admin';
import DataCard from '@/components/shared/DataCard.vue'; import DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue'; import EmptyState from '@/components/shared/EmptyState.vue';
@ -211,112 +211,164 @@ function extractRepoName(raw: string) {
return cleaned; return cleaned;
} }
// 线 / meta // 线 meta( tokens )
const BIZ_META: Record<string, { label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = { const BIZ_META: Record<string, { label: string; dot: string }> = {
airhubs: { label: 'airhubs', color: 'success' }, airhubs: { label: 'airhubs', dot: 'oklch(0.58 0.13 155)' }, // 绿
airflow: { label: 'airflow', color: 'info' }, airflow: { label: 'airflow', dot: 'oklch(0.32 0.05 200)' }, //
aircore: { label: 'aircore', color: 'warning' }, aircore: { label: 'aircore', dot: 'oklch(0.72 0.16 65)' }, //
}; };
const TYPE_META: Record<string, { label: string; emoji: string }> = { const TYPE_META: Record<string, { label: string }> = {
hardware: { label: '硬件', emoji: '🔧' }, hardware: { label: '硬件' },
software: { label: '软件', emoji: '💻' }, software: { label: '软件' },
}; };
// options
const bizOptions = computed(() => [
{ label: `airhubs · 硬件与潮玩 (${bizCounts.value.airhubs || 0})`, value: 'airhubs', disabled: !(bizCounts.value.airhubs > 0) },
{ label: `airflow · 内容与效能 (${bizCounts.value.airflow || 0})`, value: 'airflow', disabled: !(bizCounts.value.airflow > 0) },
{ label: `aircore · 技术基座 (${bizCounts.value.aircore || 0})`, value: 'aircore', disabled: !(bizCounts.value.aircore > 0) },
]);
const typeOptions = computed(() => [
{ label: `硬件 (${typeCounts.value.hardware || 0})`, value: 'hardware', disabled: !(typeCounts.value.hardware > 0) },
{ label: `软件 (${typeCounts.value.software || 0})`, value: 'software', disabled: !(typeCounts.value.software > 0) },
]);
// //
const columns = [ const columns = [
{ {
title: '产品线', title: '产品线',
key: 'bizSystem', key: 'bizSystem',
width: 200, width: 140,
render: (row: any) => { render: (row: any) => {
if (!row.bizSystem && !row.projectType) { if (!row.bizSystem) {
return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未分类'); return h('span', { class: 'cell-muted' }, '—');
} }
const biz = row.bizSystem ? BIZ_META[row.bizSystem] : null; const biz = BIZ_META[row.bizSystem];
const type = row.projectType ? TYPE_META[row.projectType] : null; return h('div', { class: 'biz-cell' }, [
return h('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [ h('span', { class: 'biz-dot', style: { background: biz.dot } }),
biz ? h(NTag, { size: 'small', type: biz.color, round: true }, { default: () => biz.label }) : null, h('span', { class: 'biz-name' }, biz.label),
type ? h(NTag, { size: 'small', type: 'default', round: true }, { default: () => `${type.emoji} ${type.label}` }) : null,
]); ]);
}, },
}, },
{ title: '项目名称', key: 'name', ellipsis: { tooltip: true } }, {
title: '类型',
key: 'projectType',
width: 100,
render: (row: any) => {
if (!row.projectType) return h('span', { class: 'cell-muted' }, '—');
const type = TYPE_META[row.projectType];
const typeClass = row.projectType === 'hardware' ? 'type-hw' : 'type-sw';
return h('span', { class: ['type-tag', typeClass] }, type.label);
},
},
{ title: '项目名称', key: 'name', ellipsis: { tooltip: true }, render: (row: any) =>
h('span', { class: 'cell-name' }, row.name)
},
{ {
title: '绑定仓库', title: '绑定仓库',
key: 'repos', key: 'repos',
width: 160, width: 200,
render: (row: any) => { render: (row: any) => {
const repos = row.repos || []; const repos = row.repos || [];
if (!repos.length) return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定'); if (!repos.length) return h('span', { class: 'cell-muted' }, '未绑定');
return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' }, return h('div', { class: 'repo-cell' },
repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => extractRepoName(r.repoName) })) repos.map((r: any) => h('span', { class: 'repo-tag' }, extractRepoName(r.repoName)))
); );
}, },
}, },
{ {
title: '操作', title: '',
key: 'actions', key: 'actions',
width: 250, width: 200,
align: 'right' as const,
render: (row: any) => { render: (row: any) => {
return h('div', { style: 'display:flex;gap:6px' }, [ const items: any[] = [
h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }), h('button', {
canCreate class: 'row-action',
? h(NButton, { size: 'tiny', type: 'warning', onClick: () => openEditModal(row) }, { default: () => '编辑' }) onClick: (e: Event) => { e.stopPropagation(); router.push(`/projects/${row.id}`); }
: null, }, '查看 →'),
canCreate ];
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' }) if (canCreate) {
: null, items.push(h('button', {
userRole === 'admin' class: 'row-action',
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' }) onClick: (e: Event) => { e.stopPropagation(); openEditModal(row); }
: null, }, '编辑'));
]); items.push(h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); openRepoModal(row); }
}, '仓库'));
}
if (userRole === 'admin') {
items.push(h('button', {
class: 'row-action row-action-danger',
onClick: (e: Event) => { e.stopPropagation(); handleDelete(row.id, row.name); }
}, '删除'));
}
return h('div', { class: 'action-cell' }, items);
}, },
}, },
]; ];
function handleRowProps(row: any) {
return {
class: 'project-row',
style: { cursor: 'pointer' },
onClick: () => router.push(`/projects/${row.id}`),
};
}
</script> </script>
<template> <template>
<div class="project-list-page"> <div class="project-list-page">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-5)">
<h2>项目列表</h2>
<NButton v-if="canCreate" type="primary" @click="showCreateModal = true">创建项目</NButton>
</div>
<NSpin :show="loading"> <NSpin :show="loading">
<DataCard v-if="projects.length" title="全部项目" :subtitle="`显示 ${filteredProjects.length} / ${projects.length} 个项目`"> <section v-if="projects.length" class="list-card">
<div class="filter-bar"> <div class="list-card-header">
<div class="filter-row"> <div class="list-title-row">
<span class="filter-label">产品线</span> <div class="title-block">
<button <h2 class="list-title">全部项目</h2>
v-for="k in ['airhubs', 'airflow', 'aircore']" :key="k" <span class="list-subtitle">显示 <span class="tabular-nums">{{ filteredProjects.length }}</span> / <span class="tabular-nums">{{ projects.length }}</span> </span>
class="chip" </div>
:class="{ active: selectedBiz.includes(k), disabled: (bizCounts[k] || 0) === 0 }" <div class="filter-bar">
:style="selectedBiz.includes(k) ? { background: chipColor(k), color: '#fff', borderColor: chipColor(k) } : {}" <div class="filter-cell">
:disabled="(bizCounts[k] || 0) === 0" <label class="filter-label">产品线</label>
@click="toggleBiz(k)" <NSelect
>{{ k }} <span class="chip-count">{{ bizCounts[k] || 0 }}</span></button> v-model:value="selectedBiz"
</div> multiple
<div class="filter-row"> clearable
<span class="filter-label">软硬件</span> placeholder="全部"
<button :options="bizOptions"
class="chip" size="small"
:class="{ active: selectedType.includes('hardware'), disabled: (typeCounts.hardware || 0) === 0 }" style="width: 220px"
:style="selectedType.includes('hardware') ? { background: chipColor('hardware'), color: '#fff', borderColor: chipColor('hardware') } : {}" />
:disabled="(typeCounts.hardware || 0) === 0" </div>
@click="toggleType('hardware')" <div class="filter-cell">
>🔧 硬件 <span class="chip-count">{{ typeCounts.hardware || 0 }}</span></button> <label class="filter-label">软硬件</label>
<button <NSelect
class="chip" v-model:value="selectedType"
:class="{ active: selectedType.includes('software'), disabled: (typeCounts.software || 0) === 0 }" multiple
:style="selectedType.includes('software') ? { background: chipColor('software'), color: '#fff', borderColor: chipColor('software') } : {}" clearable
:disabled="(typeCounts.software || 0) === 0" placeholder="全部"
@click="toggleType('software')" :options="typeOptions"
>💻 软件 <span class="chip-count">{{ typeCounts.software || 0 }}</span></button> size="small"
<NButton v-if="selectedBiz.length > 0 || selectedType.length > 0" size="tiny" text type="primary" @click="clearFilters">清空筛选</NButton> style="width: 160px"
/>
</div>
<NButton v-if="selectedBiz.length > 0 || selectedType.length > 0" size="small" text type="primary" @click="clearFilters">清空</NButton>
</div>
<NButton v-if="canCreate" type="primary" size="small" @click="showCreateModal = true">+ 创建项目</NButton>
</div> </div>
</div> </div>
<NDataTable :columns="columns" :data="filteredProjects" :bordered="false" size="small" /> <NDataTable
</DataCard> class="projects-table"
:columns="columns"
:data="filteredProjects"
:bordered="false"
:single-line="false"
size="medium"
:row-props="handleRowProps"
/>
</section>
<EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" /> <EmptyState v-else-if="!loading" title="暂无项目" description="点击「创建项目」开始添加。" />
</NSpin> </NSpin>
@ -384,8 +436,65 @@ const columns = [
</template> </template>
<style scoped> <style scoped>
.project-list-page { max-width: 960px; } .project-list-page {
.filter-bar { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--n-border-color, #eef0f3); } padding: 0 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── List card 容器 ─────── */
.list-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
overflow: hidden;
}
.list-card-header {
padding: var(--space-5) var(--space-6) var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.list-title-row {
display: flex;
align-items: flex-end;
gap: var(--space-6);
flex-wrap: wrap;
}
.title-block {
display: flex;
align-items: baseline;
gap: var(--space-3);
padding-bottom: 6px; /* 跟 select 底线对齐 */
}
.list-title {
font-family: var(--font-sans);
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
margin: 0;
}
.list-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.filter-bar {
display: flex;
align-items: flex-end;
gap: var(--space-4);
flex: 1; /* 填充中间空间 */
justify-content: center;
flex-wrap: wrap;
}
.filter-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: var(--weight-medium);
}
.filter-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .filter-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: var(--color-text-muted); min-width: 48px; } .filter-label { font-size: 12px; color: var(--color-text-muted); min-width: 48px; }
.chip { .chip {
@ -406,4 +515,102 @@ const columns = [
.chip.active { font-weight: 600; } .chip.active { font-weight: 600; }
.chip.disabled { opacity: 0.35; cursor: not-allowed; } .chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count { margin-left: 4px; opacity: 0.7; font-size: 11px; font-weight: normal; } .chip-count { margin-left: 4px; opacity: 0.7; font-size: 11px; font-weight: normal; }
/* 表格基线由 global.css 统一控制,这里只保留 hover 整行 cursor 提示 */
/* ─────── 单元格内容 ─────── */
:deep(.biz-cell) {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
}
:deep(.biz-dot) {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
:deep(.biz-name) {
color: var(--color-text-primary);
font-weight: var(--weight-medium);
}
:deep(.biz-sep) { color: var(--color-border); }
:deep(.biz-type) { color: var(--color-text-secondary); }
/* 类型 tag */
:deep(.type-tag) {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
border: 1px solid;
}
:deep(.type-tag.type-hw) {
color: oklch(0.40 0.10 280);
background: oklch(0.97 0.02 280);
border-color: oklch(0.92 0.04 280);
}
:deep(.type-tag.type-sw) {
color: oklch(0.38 0.07 220);
background: oklch(0.97 0.012 220);
border-color: oklch(0.91 0.015 220);
}
:deep(.cell-name) {
color: var(--color-text-primary);
font-weight: var(--weight-medium);
font-size: var(--text-sm);
}
:deep(.cell-muted) {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-style: italic;
}
:deep(.repo-cell) {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
:deep(.repo-tag) {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-secondary);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-subtle);
padding: 1px 8px;
border-radius: var(--radius-sm);
letter-spacing: -0.01em;
}
/* ─────── 操作按钮 (低调文本链接,默认全部显示) ─────── */
:deep(.action-cell) {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
:deep(.row-action) {
background: none;
border: none;
padding: 4px 0;
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
:deep(.row-action:hover) {
color: var(--color-primary);
}
:deep(.row-action-danger) {
color: var(--color-error);
opacity: 0.7;
}
:deep(.row-action-danger:hover) {
color: var(--color-error);
opacity: 1;
}
</style> </style>

View File

@ -128,9 +128,9 @@ function fmtPercent(n: number | null | undefined): string {
} }
function roiColor(n: number | null | undefined): string { function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)'; if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return '#0D9668'; if (n >= 100) return 'var(--color-success)';
if (n >= 0) return '#D4920A'; if (n >= 0) return 'var(--color-warning)';
return '#DC2626'; return 'var(--color-error)';
} }
function bepDisplay(): string { function bepDisplay(): string {
const bep = lifetimeAgg.value?.bepDays; const bep = lifetimeAgg.value?.bepDays;
@ -145,53 +145,68 @@ const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
<template> <template>
<div class="roi-board-page"> <div class="roi-board-page">
<NSpin :show="loading"> <NSpin :show="loading">
<!-- Header --> <!-- Editorial 页头 -->
<div class="board-header"> <header class="board-header">
<div style="display:flex;align-items:center;gap:12px"> <div class="header-left">
<NButton size="small" @click="router.push(`/projects/${projectId}`)"> 返回项目</NButton> <button class="back-link" @click="router.push(`/projects/${projectId}`)">
<h2 style="margin:0;font-size:18px" v-if="project">{{ project.name }} · ROI 看板</h2> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回项目
</button>
<div class="eyebrow" v-if="project">ROI Board</div>
<h1 class="board-title" v-if="project">{{ project.name }}</h1>
<p class="board-lede" v-if="lifetimeAgg">立项以来 · 累计 ROI <span class="tabular-nums" :style="{ color: roiColor(lifetimeAgg.roiValue) }">{{ fmtPercent(lifetimeAgg.roiValue) }}</span> · {{ bepDisplay() }}</p>
</div> </div>
<div v-if="authStore.canEdit" style="display:flex;gap:8px"> <div class="header-actions" v-if="authStore.canEdit">
<NButton size="small" @click="entryModalType = 'cost'; showEntryModal = true">+ 录入成本</NButton> <button class="action-btn action-btn-secondary" @click="entryModalType = 'cost'; showEntryModal = true">
<NButton size="small" type="primary" @click="entryModalType = 'revenue'; showEntryModal = true">+ 录入产出</NButton> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入成本
</button>
<button class="action-btn action-btn-primary" @click="entryModalType = 'revenue'; showEntryModal = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入产出
</button>
</div> </div>
</div> </header>
<!-- 顶部 4 张大卡片 --> <!-- 顶部 4 张大卡片 -->
<div class="kpi-grid"> <div class="kpi-grid">
<NCard size="small"> <article class="kpi-card">
<div class="kpi-label">历史总造价</div> <div class="kpi-label">历史总造价</div>
<div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div> <div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div> <div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div>
</NCard> </article>
<NCard size="small"> <article class="kpi-card">
<div class="kpi-label">历史总产出</div> <div class="kpi-label">历史总产出</div>
<div class="kpi-value tabular-nums" style="color:#0D9668">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div> <div class="kpi-value tabular-nums" style="color: var(--color-success)">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 {{ fmtCurrency(lifetimeAgg?.netProfit) }}</div> <div class="kpi-sub" v-if="lifetimeAgg">净利 <span class="tabular-nums">{{ fmtCurrency(lifetimeAgg?.netProfit) }}</span></div>
</NCard> </article>
<NCard size="small"> <article class="kpi-card kpi-card-highlight">
<div class="kpi-label">累计 ROI</div> <div class="kpi-label">累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }"> <div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }">
{{ fmtPercent(lifetimeAgg?.roiValue) }} {{ fmtPercent(lifetimeAgg?.roiValue) }}
</div> </div>
<div class="kpi-sub" :style="{ color: isBepWarn ? '#DC2626' : 'var(--color-text-muted)' }"> <div class="kpi-sub kpi-sub-confidence" :class="{ 'kpi-sub-warn': isBepWarn }">
<ConfidenceBadge v-if="lifetimeAgg" :confidence="lifetimeAgg.confidence" :show-label="false" /> <ConfidenceBadge v-if="lifetimeAgg" :confidence="lifetimeAgg.confidence" :show-label="false" />
<span style="margin-left:6px">{{ bepDisplay() }}</span> <span>{{ bepDisplay() }}</span>
</div> </div>
</NCard> </article>
<NCard size="small"> <article class="kpi-card">
<div class="kpi-label">本月 ROI 趋势</div> <div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }"> <div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }">
{{ fmtPercent(monthAgg?.roiValue) }} {{ fmtPercent(monthAgg?.roiValue) }}
</div> </div>
<div class="kpi-sub" v-if="monthAgg">本月成本 {{ fmtCurrency(monthAgg.totalCost) }} / 产出 {{ fmtCurrency(monthAgg.totalRevenue) }}</div> <div class="kpi-sub" v-if="monthAgg">成本 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalCost) }}</span> · 产出 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalRevenue) }}</span></div>
</NCard> </article>
</div> </div>
<!-- YTD 提示 --> <!-- YTD 摘要(单行替代 chip) -->
<div v-if="ytdAgg" class="ytd-strip"> <div v-if="ytdAgg" class="ytd-strip">
<NTag round size="small">本年 (YTD) ROI: <strong style="margin-left:4px">{{ fmtPercent(ytdAgg.roiValue) }}</strong></NTag> <span class="ytd-label">YTD 本年</span>
<NTag round size="small" type="info">YTD 成本 {{ fmtCurrency(ytdAgg.totalCost) }} / 产出 {{ fmtCurrency(ytdAgg.totalRevenue) }}</NTag> <span class="ytd-metric"><span class="ytd-key">ROI</span><span class="ytd-num tabular-nums" :style="{ color: roiColor(ytdAgg.roiValue) }">{{ fmtPercent(ytdAgg.roiValue) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">成本</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalCost) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">产出</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalRevenue) }}</span></span>
</div> </div>
<!-- 折线图 --> <!-- 折线图 -->
@ -270,28 +285,221 @@ const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
</template> </template>
<style scoped> <style scoped>
.roi-board-page { padding: var(--space-4); } .roi-board-page {
.board-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; } padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── Editorial 页头 ─────── */
.board-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: var(--space-6);
padding-bottom: var(--space-6);
margin-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.header-left { min-width: 0; flex: 1; }
.back-link {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: var(--text-sm);
color: var(--color-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-1);
transition: all var(--duration-fast) var(--ease-out);
margin-bottom: var(--space-3);
}
.back-link:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); background: var(--color-bg-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-2);
}
.board-title {
font-family: var(--font-sans);
font-size: var(--text-display);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
line-height: var(--leading-snug);
margin: 0;
}
.board-lede {
margin-top: var(--space-3);
font-size: var(--text-md);
color: var(--color-text-secondary);
line-height: var(--leading-normal);
}
.header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
.action-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
border: 1px solid;
}
.action-btn-secondary {
background: var(--color-bg-card);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.action-btn-secondary:hover { background: var(--color-bg-subtle); border-color: var(--color-border-strong); }
.action-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-onDark);
}
.action-btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); }
/* ─────── KPI 卡片 ─────── */
.kpi-grid { .kpi-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px; gap: clamp(var(--space-3), 1vw, var(--space-5));
} }
.kpi-label { font-size: 13px; color: var(--color-text-muted); } @media (min-width: 1280px) {
.kpi-value { font-size: 26px; font-weight: 700; margin-top: 6px; } .kpi-grid { grid-template-columns: 1.3fr 1fr 1.3fr 1fr; }
.kpi-sub { font-size: 12px; color: var(--color-text-muted); margin-top: 6px; display:flex; align-items:center; } }
.ytd-strip { display: flex; gap: 8px; margin-top: 12px; } .kpi-card {
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } background: var(--color-bg-card);
.factor-row { margin: 8px 0; line-height: 1.5; } border: 1px solid var(--color-border-subtle);
.event-count { border-radius: var(--radius-xl);
font-size: 12px; padding: var(--space-6);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out);
}
.kpi-card:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.kpi-card.kpi-card-highlight {
border-left: 3px solid var(--color-primary);
background: var(--color-bg-card);
}
.kpi-card.kpi-card-highlight .kpi-value {
font-size: clamp(40px, 3.6vw, 52px);
letter-spacing: -0.035em;
}
.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); color: var(--color-text-muted);
background: rgba(0,0,0,0.05); display: flex;
padding: 2px 8px; align-items: center;
border-radius: 999px; gap: var(--space-2);
} }
@media (max-width: 1024px) { .kpi-suffix {
.kpi-grid { grid-template-columns: repeat(2, 1fr); } font-size: 10px;
.two-col { grid-template-columns: 1fr; } color: var(--color-text-muted);
padding: 1px 5px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
font-weight: var(--weight-regular);
}
.kpi-value {
font-family: var(--font-mono);
font-size: 32px;
font-weight: var(--weight-semibold);
letter-spacing: -0.025em;
line-height: 1;
margin-top: var(--space-3);
}
.kpi-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-subtle);
line-height: var(--leading-snug);
}
.kpi-sub-confidence {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.kpi-sub-warn { color: var(--color-error); }
/* ─────── YTD 简单 strip(节奏化:小标签 + 大数字) ─────── */
.ytd-strip {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-5);
padding: var(--space-3) var(--space-5);
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
flex-wrap: wrap;
}
.ytd-label {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.ytd-metric {
display: inline-flex;
gap: var(--space-2);
align-items: baseline;
}
.ytd-key {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ytd-num {
font-size: 15px;
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
}
.ytd-sep {
display: inline-block;
width: 1px;
height: 12px;
background: var(--color-border);
}
/* ─────── 通用 ─────── */
.two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.factor-row { margin: var(--space-2) 0; line-height: var(--leading-normal); display: flex; align-items: flex-start; gap: var(--space-2); }
.event-count {
font-size: var(--text-xs);
color: var(--color-text-muted);
background: var(--color-bg-subtle);
padding: 2px 10px;
border-radius: var(--radius-full);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-medium);
}
/* auto-fit + minmax 已经处理窄屏自适应,只在很窄时调整 header 布局 */
@media (max-width: 640px) {
.board-header { flex-direction: column; align-items: flex-start; }
.header-actions { width: 100%; justify-content: flex-end; }
} }
</style> </style>

View File

@ -160,49 +160,57 @@ const projectColumns = [
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<NSpin :show="loading"> <NSpin :show="loading">
<div class="dashboard-header">
<h2 style="margin:0">管理者决策罗盘</h2>
</div>
<!-- 3 ROI 大卡片 --> <!-- 3 ROI 大卡片 -->
<div class="kpi-grid"> <div class="kpi-grid">
<NCard size="small"> <article class="kpi-card kpi-hero">
<div class="kpi-label">公司累计 ROI</div> <div class="kpi-label">公司累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }"> <div class="kpi-value-row">
{{ fmtPercent(lifetimeData?.summary.roiValue) }} <span class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }">
{{ fmtPercent(lifetimeData?.summary.roiValue) }}
</span>
</div> </div>
<div class="kpi-sub" v-if="lifetimeData"> <div class="kpi-meta" v-if="lifetimeData">
成本 {{ fmtCurrency(lifetimeData.summary.totalCost) }} / 产出 {{ fmtCurrency(lifetimeData.summary.totalRevenue) }} <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> </div>
</NCard> </article>
<NCard size="small"> <article class="kpi-card">
<div class="kpi-label">本月 ROI</div> <div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }"> <div class="kpi-value-row">
{{ fmtPercent(mtdData?.summary.roiValue) }} <span class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }">
{{ fmtPercent(mtdData?.summary.roiValue) }}
</span>
</div> </div>
<div class="kpi-sub" v-if="mtdData"> <div class="kpi-meta" v-if="mtdData">
成本 {{ fmtCurrency(mtdData.summary.totalCost) }} / 产出 {{ fmtCurrency(mtdData.summary.totalRevenue) }} <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> </div>
</NCard> </article>
<NCard size="small"> <article class="kpi-card">
<div class="kpi-label">本年累计 ROI (YTD)</div> <div class="kpi-label">本年 ROI<span class="kpi-suffix">YTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }"> <div class="kpi-value-row">
{{ fmtPercent(ytdData?.summary.roiValue) }} <span class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }">
{{ fmtPercent(ytdData?.summary.roiValue) }}
</span>
</div> </div>
<div class="kpi-sub" v-if="ytdData"> <div class="kpi-meta" v-if="ytdData">
成本 {{ fmtCurrency(ytdData.summary.totalCost) }} / 产出 {{ fmtCurrency(ytdData.summary.totalRevenue) }} <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> </div>
</NCard> </article>
</div> </div>
<!-- 切换时间窗口 --> <!-- 切换时间窗口 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;margin-bottom:12px"> <div class="section-divider">
<span style="font-weight:600">业务线分布</span> <div>
<div class="eyebrow">Business lines</div>
<h2 class="section-title">业务线分布</h2>
</div>
<NSelect v-model:value="windowKey" :options="[ <NSelect v-model:value="windowKey" :options="[
{ label: '累计 (LTD)', value: 'lifetime' }, { label: '累计 (LTD)', value: 'lifetime' },
{ label: '本年 (YTD)', value: 'ytd' }, { label: '本年 (YTD)', value: 'ytd' },
{ label: '本月 (MTD)', value: 'mtd' }, { label: '本月 (MTD)', value: 'mtd' },
]" style="width:140px" size="small" /> ]" style="width:160px" size="small" />
</div> </div>
<!-- 堆叠图 + 饼图 --> <!-- 堆叠图 + 饼图 -->
@ -260,71 +268,221 @@ const projectColumns = [
</template> </template>
<style scoped> <style scoped>
.dashboard-page { padding: var(--space-4); } .dashboard-page {
.dashboard-header { margin-bottom: 16px; } padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
.kpi-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } width: 100%;
.kpi-label { font-size: 13px; color: var(--color-text-muted); } }
.kpi-value { font-size: 30px; font-weight: 700; margin-top: 6px; }
.kpi-sub { font-size: 12px; color: var(--color-text-muted); margin-top: 6px; } /* ─────── Editorial 页头 ─────── */
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .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 { .table-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
gap: 16px; gap: var(--space-4);
flex-wrap: wrap; flex-wrap: wrap;
} }
.category-chips { .category-chips {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: var(--space-2);
flex-wrap: wrap; flex-wrap: wrap;
} }
.chip { .chip {
border: 1px solid var(--n-border-color, #e5e7eb); border: 1px solid var(--color-border);
background: transparent; background: var(--color-bg-card);
border-radius: 999px; border-radius: var(--radius-full);
padding: 3px 10px; padding: 4px 12px;
font-size: 12px; font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px; gap: var(--space-1);
color: var(--color-text, #333); color: var(--color-text-secondary);
transition: all 0.15s; transition: all var(--duration-fast) var(--ease-out);
} }
.chip:hover:not(.disabled) { .chip:hover:not(.disabled) {
border-color: var(--color-primary-hex, #3B5998); border-color: var(--color-border-strong);
} color: var(--color-text-primary);
.chip.active { background: var(--color-bg-subtle);
font-weight: 600;
}
.chip.disabled {
opacity: 0.35;
cursor: not-allowed;
} }
.chip.active { font-weight: var(--weight-semibold); box-shadow: var(--shadow-sm); }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.chip-count { .chip-count {
margin-left: 4px; font-family: var(--font-mono);
opacity: 0.7; font-size: 10px;
font-size: 11px; color: var(--color-text-muted);
font-weight: normal; 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 { .result-count {
font-size: 12px; font-size: var(--text-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
} }
.filter-divider { .filter-divider { color: var(--color-border); margin: 0 var(--space-1); user-select: none; }
color: var(--color-text-muted); .filter-label { font-size: var(--text-sm); color: var(--color-text-muted); }
margin: 0 4px;
} /* 自适应:auto-fit + minmax 已经在窄屏自动单列, 这里仅微调 padding */
.filter-label { @media (max-width: 768px) {
font-size: 12px; .page-header { padding-bottom: var(--space-4); margin-bottom: var(--space-6); }
color: var(--color-text-muted); .section-divider { margin-top: var(--space-8); flex-wrap: wrap; }
}
@media (max-width: 1024px) {
.kpi-grid { grid-template-columns: 1fr; }
.charts-row { grid-template-columns: 1fr; }
} }
</style> </style>