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:
parent
5af612e3fd
commit
4a2ed8d414
@ -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;
|
||||||
|
|||||||
@ -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'));
|
||||||
|
|
||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
// 核心查询接口
|
// 核心查询接口
|
||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user