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));
}
// 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
const allProjects = await db.select().from(projects);
const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = [];
// ─── 性能优化:一次性批量拉取,内存里做 join,避免 N+1 ───
const [allProjects, allObjectivesRaw, allKRs, allUsersData] = await Promise.all([
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) {
if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue;
let projObjectives = await db.select().from(objectives)
.where(eq(objectives.projectId, proj.id));
let projObjectives = objectivesByProject.get(proj.id) || [];
if (period) {
projObjectives = projObjectives.filter(o => o.period === period);
}
@ -90,14 +111,11 @@ overviewRoutes.get('/overview', async (c) => {
});
}
// 2. KR 完成状态分布(替代任务状态分布)
const allKRs = await db.select().from(keyResults);
// 2. KR 完成状态分布
let filteredKRs = allKRs;
if (mustFilterByProject || projectIds.length > 0) {
const projObjIds = new Set(
(await db.select().from(objectives))
.filter(o => o.projectId && projectIds.includes(o.projectId))
.map(o => o.id)
allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id)
);
filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId));
}
@ -125,16 +143,17 @@ overviewRoutes.get('/overview', async (c) => {
// 4. Weekly Code Activity (last 12 weeks)
const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate();
let commits = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, twelveWeeksAgo));
let prs = await db.select().from(gitPRs)
.where(gte(gitPRs.createdAt, twelveWeeksAgo));
// 观察者:按项目绑定仓库过滤
const [commitsRaw, prsRaw] = await Promise.all([
db.select().from(gitCommits).where(gte(gitCommits.committedAt, twelveWeeksAgo)),
db.select().from(gitPRs).where(gte(gitPRs.createdAt, twelveWeeksAgo)),
]);
let commits = commitsRaw;
let prs = prsRaw;
if (allowedRepos) {
commits = commits.filter(c => allowedRepos.has(c.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 }>> = {};
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
? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives);
? allObjectivesRaw.filter(o => o.period === period)
: allObjectivesRaw;
if (mustFilterByProject || projectIds.length > 0) {
allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId));
}
const okrProgress = [];
for (const obj of allObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
okrProgress.push({
const okrProgress = allObjectives.map(obj => {
const krs = krsByObjective.get(obj.id) || [];
const owner = obj.ownerId ? usersById.get(obj.ownerId) : null;
return {
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
@ -199,17 +214,16 @@ overviewRoutes.get('/overview', async (c) => {
target: kr.targetValue,
unit: kr.unit || '',
})),
});
}
};
});
// 6. 指定周的 KR支持 weekOffset 参数0=本周1=下周,-1=上周)
const weekOffset = parseInt(c.req.query('weekOffset') || '0');
const weekStart = dayjs().startOf('week').add(weekOffset, 'week');
const weekEnd = dayjs().endOf('week').add(weekOffset, 'week');
let allKRsRaw = await db.select().from(keyResults);
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
// 观察者:过滤到已分配项目的 KR
let allKRsRaw = allKRs; // 复用前面 fetch
const allObjsMap = objectivesById;
if (allowedObjIds) {
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
}
@ -231,12 +245,8 @@ overviewRoutes.get('/overview', async (c) => {
const urgentKRs = [];
for (const kr of thisWeekKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
const endDate = kr.endDate || '';
const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day'));
@ -288,22 +298,24 @@ overviewRoutes.get('/overview', async (c) => {
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 = [];
for (const kr of abnormalKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
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 || '';
const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null;
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null;
const reason = lastLogByKr.get(kr.id) || '';
let itemStatus: string;
let statusLabel: string;

View File

@ -23,6 +23,9 @@ import { applyAutoIdentifier } from '../services/roi/identifier-generator';
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 { db } from '../db/index';
import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
@ -6,24 +6,56 @@ import { desc } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
export async function getOKRByPeriod(period?: string) {
// 1. 拿 objectives(按 period 可选过滤)
const allObjectives = period
? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives);
const result = [];
for (const obj of allObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
if (allObjectives.length === 0) return { objectives: [] };
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const objIds = allObjectives.map(o => o.id);
const ownerIds = Array.from(new Set(allObjectives.map(o => o.ownerId).filter(Boolean) as string[]));
const projectIds = Array.from(new Set(allObjectives.map(o => o.projectId).filter(Boolean) as string[]));
const project = obj.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
// 2. 一次性批量拉 KRs / users / projects
const [allKRs, allOwners, allProjects] = await Promise.all([
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,
title: obj.title,
ownerName: owner?.displayName || '未指定',
@ -33,15 +65,8 @@ export async function getOKRByPeriod(period?: string) {
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => {
// 查是否有延期记录
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;
keyResults: krs.map(kr => {
const lastPostponeReason = postponedByKr.get(kr.id) ?? null;
return {
id: kr.id,
title: kr.title,
@ -50,7 +75,7 @@ export async function getOKRByPeriod(period?: string) {
unit: kr.unit || '',
weight: kr.weight || 1,
status: kr.status || 'active',
wasPostponed,
wasPostponed: lastPostponeReason !== null,
lastPostponeReason,
startDate: kr.startDate || null,
endDate: kr.endDate || null,
@ -58,9 +83,9 @@ export async function getOKRByPeriod(period?: string) {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
})),
});
}
}),
};
});
return { objectives: result };
}

View File

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

View File

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

View File

@ -7,13 +7,22 @@ const props = defineProps<{
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
cash_cow: '现金牛',
efficiency_tool: '效能工具',
moat: '资本护城河',
composite: '复合型',
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 data = Object.entries(props.byCategory)
.filter(([, v]) => v.totalRevenue > 0)
@ -21,13 +30,74 @@ const option = computed(() => {
return {
color: CHART_COLORS,
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', top: 'middle', textStyle: { fontSize: 12 } },
textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' },
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: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['65%', '50%'],
label: { formatter: '{b}\n{d}%' },
radius: ['58%', '78%'],
center: ['50%', '45%'],
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,
}],
};

View File

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

View File

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

View File

@ -12,7 +12,46 @@ echarts.use([
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

View File

@ -35,12 +35,13 @@ const router = createRouter({
path: 'projects/:id/roi',
name: 'ProjectRoiBoard',
component: () => import('@/views/ProjectRoiBoard.vue'),
meta: { roles: ['admin'] },
},
{
path: 'roi',
name: 'RoiDashboard',
component: () => import('@/views/RoiDashboard.vue'),
meta: { roles: ['admin', 'manager'] },
meta: { roles: ['admin'] },
},
// 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 {
/* Primary - Trusted Indigo */
--color-primary: oklch(0.45 0.12 255);
--color-primary-hex: #3B5998;
--color-primary-hover: oklch(0.40 0.12 255);
--color-primary-light: oklch(0.92 0.03 255);
/* ─────────── 色彩系统 (OKLCH) ─────────── */
/* 主色 — 深墨青(克制信任, 不是 Tailwind blue) */
--color-primary: oklch(0.32 0.05 200);
--color-primary-hover: oklch(0.27 0.05 200);
--color-primary-press: oklch(0.23 0.05 200);
--color-primary-soft: oklch(0.94 0.018 200);
/* Accent - Amber */
--color-accent: oklch(0.75 0.15 75);
--color-accent-hex: #D4920A;
/* 强调色 — 暖琥珀(数据高亮 / CTA) */
--color-accent: oklch(0.72 0.16 65);
--color-accent-hover: oklch(0.66 0.17 60);
--color-accent-soft: oklch(0.96 0.04 75);
/* Semantic */
--color-success: #0D9668;
--color-warning: #D4920A;
--color-error: #DC2626;
--color-info: #2B8CA3;
/* 语义色 */
--color-success: oklch(0.58 0.13 155);
--color-success-soft: oklch(0.95 0.04 155);
--color-warning: oklch(0.72 0.16 65);
--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 */
--chart-1: #3B5998;
--chart-2: #0D9668;
--chart-3: #D4920A;
--chart-4: #7C4DBA;
--chart-5: #2B8CA3;
/* 中性色阶(暖灰, chroma 0.005~0.012, 不是死灰) */
--color-bg: oklch(0.985 0.003 80); /* 页面背景 */
--color-bg-card: oklch(1.000 0.000 0); /* 卡片 */
--color-bg-subtle: oklch(0.965 0.004 80); /* 浅灰区块 */
--color-bg-hover: oklch(0.955 0.005 80); /* hover */
--color-bg-sidebar: oklch(0.18 0.012 230); /* 侧边栏深底带蓝绿 */
--color-bg-sidebar-2: oklch(0.21 0.013 230); /* 侧边栏次级 */
/* Neutral */
--color-bg: #F8F9FB;
--color-bg-card: #FFFFFF;
--color-bg-sidebar: #1E2433;
--color-text-primary: #1A1F2E;
--color-text-secondary: #6B7280;
--color-text-muted: #9CA3AF;
--color-border: #E5E7EB;
--color-border-subtle: oklch(0.935 0.006 80);
--color-border: oklch(0.88 0.008 80);
--color-border-strong: oklch(0.78 0.010 80);
/* Typography */
--font-heading: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-code: 'JetBrains Mono', 'Fira Code', monospace;
--color-text-muted: oklch(0.58 0.009 80);
--color-text-secondary: oklch(0.42 0.011 80);
--color-text-primary: oklch(0.22 0.012 80);
--color-text-onDark: oklch(0.92 0.005 80);
--color-text-onDarkMuted:oklch(0.65 0.010 220);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* 兼容老变量 (保持现有组件不破) */
--color-primary-hex: #1f3a45;
--color-accent-hex: #c47918;
--color-text-secondary-legacy: var(--color-text-secondary);
/* Border radius */
--radius-btn: 8px;
--radius-card: 12px;
--radius-modal: 16px;
--radius-pill: 9999px;
/* ─────────── 图表色板(OKLCH 衍生, 8 色, 中等饱和) ─────────── */
--chart-1: oklch(0.32 0.05 200); /* 墨青 */
--chart-2: oklch(0.58 0.13 155); /* 翠绿 */
--chart-3: oklch(0.72 0.16 65); /* 琥珀 */
--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);
--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);
--duration-hover: 200ms;
--duration-entrance: 600ms;
--duration-collapse: 300ms;
/* ─────────── 字体 ─────────── */
/* Display: Newsreader 衬线(editorial 标题) */
--font-display: 'Newsreader', 'Source Serif 4', 'Songti SC', 'STSong', Georgia, serif;
/* Sans: Geist(Vercel 现代无衬线, 替代 Inter) + PingFang */
--font-sans: 'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
/* 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-sticky: 200;
--z-modal: 300;
--z-toast: 9999;
--z-sticky: 200;
--z-modal: 300;
--z-toast: 9999;
/* Sidebar */
--sidebar-width: 240px;
/* ─────────── Layout ─────────── */
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--content-max: 1440px;
}
/* ─────────── Reset ─────────── */
*,
*::before,
*::after {
@ -87,58 +191,158 @@ html, body {
}
html {
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text-primary);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'ss01', 'cv01', 'cv11';
}
body {
min-height: 100vh;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.3;
/* 标题: 中文用 sans(PingFang)更协调, 衬线只用在英文 eyebrow */
h1, h2, h3 {
font-family: var(--font-sans);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
}
code, pre {
font-family: var(--font-code);
h4, h5, h6 {
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 {
color: var(--color-primary-hex);
color: var(--color-primary);
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 {
text-decoration: underline;
/* focus-visible(键盘用户) */
:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-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;
}
/* 空状态贴齐主题 */
.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';
/**
* Naive UI global.css
* OKLCH(0.32 0.05 200) hex #1F3A45
*/
export const naiveThemeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#3B5998',
primaryColorHover: '#2D4373',
primaryColorPressed: '#1E2D4F',
primaryColorSuppl: '#3B5998',
infoColor: '#2B8CA3',
successColor: '#0D9668',
warningColor: '#D4920A',
errorColor: '#DC2626',
fontFamily: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif",
fontFamilyMono: "'JetBrains Mono', 'Fira Code', monospace",
borderRadius: '8px',
// 主色板 (OKLCH 转近似 hex)
primaryColor: '#1f3a45',
primaryColorHover: '#173039',
primaryColorPressed: '#0f2730',
primaryColorSuppl: '#1f3a45',
// 语义色 (OKLCH 转近似 hex)
infoColor: '#2d5d8c',
infoColorHover: '#264f78',
successColor: '#317a5d',
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',
// 字号
fontSize: '14px',
fontSizeSmall: '13px',
},
Button: {
borderRadiusMedium: '8px',
borderRadiusSmall: '6px',
borderRadiusLarge: '10px',
fontWeight: '500',
fontWeightStrong: '600',
paddingMedium: '0 14px',
},
Card: {
borderRadius: '12px',
borderRadius: '14px',
paddingMedium: '20px 24px',
color: '#ffffff',
borderColor: '#eaeded',
},
Dialog: {
borderRadius: '16px',
@ -31,8 +72,28 @@ export const naiveThemeOverrides: GlobalThemeOverrides = {
},
DataTable: {
borderRadius: '12px',
thColor: '#fafbfb',
thColorHover: '#f3f5f6',
thTextColor: '#4d5258',
thFontWeight: '600',
fontSize: '13px',
},
Tag: {
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 {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
}
</style>

View File

@ -332,6 +332,7 @@ function canEditObj(obj: any): boolean {
<div style="display:flex;align-items:center;gap:12px">
<h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2>
<ProjectTagSelector
v-if="authStore.isAdmin"
:project-id="projectId"
:initial-category="data.project?.category || 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-launched-at="data.project?.launchedAt || null"
:initial-v-asset="data.project?.vAsset || null"
:can-edit="authStore.canEdit"
:can-edit="true"
@saved="loadData"
/>
</div>
<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 看板
</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>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue';
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 DataCard from '@/components/shared/DataCard.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
@ -211,112 +211,164 @@ function extractRepoName(raw: string) {
return cleaned;
}
// 线 / meta
const BIZ_META: Record<string, { label: string; color: 'success' | 'info' | 'warning' | 'error' | 'default' }> = {
airhubs: { label: 'airhubs', color: 'success' },
airflow: { label: 'airflow', color: 'info' },
aircore: { label: 'aircore', color: 'warning' },
// 线 meta( tokens )
const BIZ_META: Record<string, { label: string; dot: string }> = {
airhubs: { label: 'airhubs', dot: 'oklch(0.58 0.13 155)' }, // 绿
airflow: { label: 'airflow', dot: 'oklch(0.32 0.05 200)' }, //
aircore: { label: 'aircore', dot: 'oklch(0.72 0.16 65)' }, //
};
const TYPE_META: Record<string, { label: string; emoji: string }> = {
hardware: { label: '硬件', emoji: '🔧' },
software: { label: '软件', emoji: '💻' },
const TYPE_META: Record<string, { label: string }> = {
hardware: { label: '硬件' },
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 = [
{
title: '产品线',
key: 'bizSystem',
width: 200,
width: 140,
render: (row: any) => {
if (!row.bizSystem && !row.projectType) {
return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未分类');
if (!row.bizSystem) {
return h('span', { class: 'cell-muted' }, '—');
}
const biz = row.bizSystem ? BIZ_META[row.bizSystem] : null;
const type = row.projectType ? TYPE_META[row.projectType] : null;
return h('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [
biz ? h(NTag, { size: 'small', type: biz.color, round: true }, { default: () => biz.label }) : null,
type ? h(NTag, { size: 'small', type: 'default', round: true }, { default: () => `${type.emoji} ${type.label}` }) : null,
const biz = BIZ_META[row.bizSystem];
return h('div', { class: 'biz-cell' }, [
h('span', { class: 'biz-dot', style: { background: biz.dot } }),
h('span', { class: 'biz-name' }, biz.label),
]);
},
},
{ 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: '绑定仓库',
key: 'repos',
width: 160,
width: 200,
render: (row: any) => {
const repos = row.repos || [];
if (!repos.length) return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定');
return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' },
repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => extractRepoName(r.repoName) }))
if (!repos.length) return h('span', { class: 'cell-muted' }, '未绑定');
return h('div', { class: 'repo-cell' },
repos.map((r: any) => h('span', { class: 'repo-tag' }, extractRepoName(r.repoName)))
);
},
},
{
title: '操作',
title: '',
key: 'actions',
width: 250,
width: 200,
align: 'right' as const,
render: (row: any) => {
return h('div', { style: 'display:flex;gap:6px' }, [
h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }),
canCreate
? h(NButton, { size: 'tiny', type: 'warning', onClick: () => openEditModal(row) }, { default: () => '编辑' })
: null,
canCreate
? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' })
: null,
userRole === 'admin'
? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })
: null,
]);
const items: any[] = [
h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); router.push(`/projects/${row.id}`); }
}, '查看 →'),
];
if (canCreate) {
items.push(h('button', {
class: 'row-action',
onClick: (e: Event) => { e.stopPropagation(); openEditModal(row); }
}, '编辑'));
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>
<template>
<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">
<DataCard v-if="projects.length" title="全部项目" :subtitle="`显示 ${filteredProjects.length} / ${projects.length} 个项目`">
<div class="filter-bar">
<div class="filter-row">
<span class="filter-label">产品线</span>
<button
v-for="k in ['airhubs', 'airflow', 'aircore']" :key="k"
class="chip"
:class="{ active: selectedBiz.includes(k), disabled: (bizCounts[k] || 0) === 0 }"
:style="selectedBiz.includes(k) ? { background: chipColor(k), color: '#fff', borderColor: chipColor(k) } : {}"
:disabled="(bizCounts[k] || 0) === 0"
@click="toggleBiz(k)"
>{{ k }} <span class="chip-count">{{ bizCounts[k] || 0 }}</span></button>
</div>
<div class="filter-row">
<span class="filter-label">软硬件</span>
<button
class="chip"
:class="{ active: selectedType.includes('hardware'), disabled: (typeCounts.hardware || 0) === 0 }"
:style="selectedType.includes('hardware') ? { background: chipColor('hardware'), color: '#fff', borderColor: chipColor('hardware') } : {}"
:disabled="(typeCounts.hardware || 0) === 0"
@click="toggleType('hardware')"
>🔧 硬件 <span class="chip-count">{{ typeCounts.hardware || 0 }}</span></button>
<button
class="chip"
:class="{ active: selectedType.includes('software'), disabled: (typeCounts.software || 0) === 0 }"
:style="selectedType.includes('software') ? { background: chipColor('software'), color: '#fff', borderColor: chipColor('software') } : {}"
:disabled="(typeCounts.software || 0) === 0"
@click="toggleType('software')"
>💻 软件 <span class="chip-count">{{ typeCounts.software || 0 }}</span></button>
<NButton v-if="selectedBiz.length > 0 || selectedType.length > 0" size="tiny" text type="primary" @click="clearFilters">清空筛选</NButton>
<section v-if="projects.length" class="list-card">
<div class="list-card-header">
<div class="list-title-row">
<div class="title-block">
<h2 class="list-title">全部项目</h2>
<span class="list-subtitle">显示 <span class="tabular-nums">{{ filteredProjects.length }}</span> / <span class="tabular-nums">{{ projects.length }}</span> </span>
</div>
<div class="filter-bar">
<div class="filter-cell">
<label class="filter-label">产品线</label>
<NSelect
v-model:value="selectedBiz"
multiple
clearable
placeholder="全部"
:options="bizOptions"
size="small"
style="width: 220px"
/>
</div>
<div class="filter-cell">
<label class="filter-label">软硬件</label>
<NSelect
v-model:value="selectedType"
multiple
clearable
placeholder="全部"
:options="typeOptions"
size="small"
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>
<NDataTable :columns="columns" :data="filteredProjects" :bordered="false" size="small" />
</DataCard>
<NDataTable
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="点击「创建项目」开始添加。" />
</NSpin>
@ -384,8 +436,65 @@ const columns = [
</template>
<style scoped>
.project-list-page { max-width: 960px; }
.filter-bar { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--n-border-color, #eef0f3); }
.project-list-page {
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-label { font-size: 12px; color: var(--color-text-muted); min-width: 48px; }
.chip {
@ -406,4 +515,102 @@ const columns = [
.chip.active { font-weight: 600; }
.chip.disabled { opacity: 0.35; cursor: not-allowed; }
.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>

View File

@ -128,9 +128,9 @@ function fmtPercent(n: number | null | undefined): string {
}
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';
if (n >= 100) return 'var(--color-success)';
if (n >= 0) return 'var(--color-warning)';
return 'var(--color-error)';
}
function bepDisplay(): string {
const bep = lifetimeAgg.value?.bepDays;
@ -145,53 +145,68 @@ const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
<template>
<div class="roi-board-page">
<NSpin :show="loading">
<!-- Header -->
<div class="board-header">
<div style="display:flex;align-items:center;gap:12px">
<NButton size="small" @click="router.push(`/projects/${projectId}`)"> 返回项目</NButton>
<h2 style="margin:0;font-size:18px" v-if="project">{{ project.name }} · ROI 看板</h2>
<!-- Editorial 页头 -->
<header class="board-header">
<div class="header-left">
<button class="back-link" @click="router.push(`/projects/${projectId}`)">
<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 v-if="authStore.canEdit" style="display:flex;gap:8px">
<NButton size="small" @click="entryModalType = 'cost'; showEntryModal = true">+ 录入成本</NButton>
<NButton size="small" type="primary" @click="entryModalType = 'revenue'; showEntryModal = true">+ 录入产出</NButton>
<div class="header-actions" v-if="authStore.canEdit">
<button class="action-btn action-btn-secondary" @click="entryModalType = 'cost'; 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>
<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>
</header>
<!-- 顶部 4 张大卡片 -->
<div class="kpi-grid">
<NCard size="small">
<article class="kpi-card">
<div class="kpi-label">历史总造价</div>
<div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div>
</NCard>
<NCard size="small">
</article>
<article class="kpi-card">
<div class="kpi-label">历史总产出</div>
<div class="kpi-value tabular-nums" style="color:#0D9668">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 {{ fmtCurrency(lifetimeAgg?.netProfit) }}</div>
</NCard>
<NCard size="small">
<div class="kpi-value tabular-nums" style="color: var(--color-success)">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 <span class="tabular-nums">{{ fmtCurrency(lifetimeAgg?.netProfit) }}</span></div>
</article>
<article class="kpi-card kpi-card-highlight">
<div class="kpi-label">累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }">
{{ fmtPercent(lifetimeAgg?.roiValue) }}
</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" />
<span style="margin-left:6px">{{ bepDisplay() }}</span>
<span>{{ bepDisplay() }}</span>
</div>
</NCard>
<NCard size="small">
<div class="kpi-label">本月 ROI 趋势</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }">
{{ fmtPercent(monthAgg?.roiValue) }}
</div>
<div class="kpi-sub" v-if="monthAgg">本月成本 {{ fmtCurrency(monthAgg.totalCost) }} / 产出 {{ fmtCurrency(monthAgg.totalRevenue) }}</div>
</NCard>
<div class="kpi-sub" v-if="monthAgg">成本 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalCost) }}</span> · 产出 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalRevenue) }}</span></div>
</article>
</div>
<!-- YTD 提示 -->
<!-- YTD 摘要(单行替代 chip) -->
<div v-if="ytdAgg" class="ytd-strip">
<NTag round size="small">本年 (YTD) ROI: <strong style="margin-left:4px">{{ fmtPercent(ytdAgg.roiValue) }}</strong></NTag>
<NTag round size="small" type="info">YTD 成本 {{ fmtCurrency(ytdAgg.totalCost) }} / 产出 {{ fmtCurrency(ytdAgg.totalRevenue) }}</NTag>
<span class="ytd-label">YTD 本年</span>
<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>
<!-- 折线图 -->
@ -270,28 +285,221 @@ const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
</template>
<style scoped>
.roi-board-page { padding: var(--space-4); }
.board-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
.roi-board-page {
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 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.kpi-label { font-size: 13px; color: var(--color-text-muted); }
.kpi-value { font-size: 26px; font-weight: 700; margin-top: 6px; }
.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; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.factor-row { margin: 8px 0; line-height: 1.5; }
.event-count {
font-size: 12px;
@media (min-width: 1280px) {
.kpi-grid { grid-template-columns: 1.3fr 1fr 1.3fr 1fr; }
}
.kpi-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
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);
background: rgba(0,0,0,0.05);
padding: 2px 8px;
border-radius: 999px;
display: flex;
align-items: center;
gap: var(--space-2);
}
@media (max-width: 1024px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.kpi-suffix {
font-size: 10px;
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>

View File

@ -160,49 +160,57 @@ const projectColumns = [
<template>
<div class="dashboard-page">
<NSpin :show="loading">
<div class="dashboard-header">
<h2 style="margin:0">管理者决策罗盘</h2>
</div>
<!-- 3 ROI 大卡片 -->
<div class="kpi-grid">
<NCard size="small">
<article class="kpi-card kpi-hero">
<div class="kpi-label">公司累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeData?.summary.roiValue) }">
{{ fmtPercent(lifetimeData?.summary.roiValue) }}
<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-sub" v-if="lifetimeData">
成本 {{ fmtCurrency(lifetimeData.summary.totalCost) }} / 产出 {{ fmtCurrency(lifetimeData.summary.totalRevenue) }}
<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>
</NCard>
<NCard size="small">
<div class="kpi-label">本月 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(mtdData?.summary.roiValue) }">
{{ fmtPercent(mtdData?.summary.roiValue) }}
</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-sub" v-if="mtdData">
成本 {{ fmtCurrency(mtdData.summary.totalCost) }} / 产出 {{ fmtCurrency(mtdData.summary.totalRevenue) }}
<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>
</NCard>
<NCard size="small">
<div class="kpi-label">本年累计 ROI (YTD)</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(ytdData?.summary.roiValue) }">
{{ fmtPercent(ytdData?.summary.roiValue) }}
</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-sub" v-if="ytdData">
成本 {{ fmtCurrency(ytdData.summary.totalCost) }} / 产出 {{ fmtCurrency(ytdData.summary.totalRevenue) }}
<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>
</NCard>
</article>
</div>
<!-- 切换时间窗口 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;margin-bottom:12px">
<span style="font-weight:600">业务线分布</span>
<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:140px" size="small" />
]" style="width:160px" size="small" />
</div>
<!-- 堆叠图 + 饼图 -->
@ -260,71 +268,221 @@ const projectColumns = [
</template>
<style scoped>
.dashboard-page { padding: var(--space-4); }
.dashboard-header { margin-bottom: 16px; }
.kpi-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.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; }
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.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: 16px;
gap: var(--space-4);
flex-wrap: wrap;
}
.category-chips {
display: flex;
align-items: center;
gap: 6px;
gap: var(--space-2);
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--n-border-color, #e5e7eb);
background: transparent;
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
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: 2px;
color: var(--color-text, #333);
transition: all 0.15s;
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-primary-hex, #3B5998);
}
.chip.active {
font-weight: 600;
}
.chip.disabled {
opacity: 0.35;
cursor: not-allowed;
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 {
margin-left: 4px;
opacity: 0.7;
font-size: 11px;
font-weight: normal;
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: 12px;
font-size: var(--text-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.filter-divider {
color: var(--color-text-muted);
margin: 0 4px;
}
.filter-label {
font-size: 12px;
color: var(--color-text-muted);
}
@media (max-width: 1024px) {
.kpi-grid { grid-template-columns: 1fr; }
.charts-row { grid-template-columns: 1fr; }
.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>