Compare commits

..

No commits in common. "7b5a2a823ceec7310fa0c89f025090ebedb6ff32" and "ad66228edc3c3995a2bceb8c4c61cbcfaec58220" have entirely different histories.

60 changed files with 245 additions and 5983 deletions

View File

@ -1,122 +0,0 @@
-- ROI 动态规则引擎(v2.0) — 事件流模型
-- 包含: projects 扩展字段、roi_strategies、project_cost_events、project_revenue_events、
-- project_revenue_mapping、unmapped_revenue_events、sync_logs.source 枚举扩展
-- ── 1. 扩展 projects 表 ──
ALTER TABLE `projects` ADD COLUMN `category` enum('cash_cow','efficiency_tool','moat','composite') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `composite_strategies` json NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `owner_id` varchar(50) NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `tags` json NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `launched_at` datetime NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `v_asset` double NULL;
--> statement-breakpoint
CREATE INDEX `idx_projects_category` ON `projects` (`category`);
--> statement-breakpoint
-- ── 2. 扩展 sync_logs.source 枚举 ──
ALTER TABLE `sync_logs` MODIFY COLUMN `source` enum('plane','gitea','ai_okr','roi_cost_ingest','roi_revenue_ingest','roi_amortizer','roi_ai_driver') NOT NULL;
--> statement-breakpoint
-- ── 3. roi_strategies ──
CREATE TABLE IF NOT EXISTS `roi_strategies` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`category` enum('cash_cow','efficiency_tool','moat','composite') NOT NULL,
`name` varchar(200) NOT NULL,
`formula_key` varchar(100) NOT NULL,
`params` json NOT NULL,
`updated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_roi_strategy_category` ON `roi_strategies` (`category`);
--> statement-breakpoint
-- ── 4. project_cost_events ──
CREATE TABLE IF NOT EXISTS `project_cost_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`event_date` datetime NOT NULL,
`cost_type` enum('dev_hours','hardware_bom','service_fee','amortization','other') NOT NULL,
`amount` double NOT NULL,
`hours` double NULL,
`hourly_rate_used` double NULL,
`data_source` enum('auto_commits','auto_tasks','plane_actual','manual','amortization_cron') NOT NULL,
`ref_type` varchar(50) NULL,
`ref_id` varchar(200) NULL,
`notes` text NULL,
`created_by` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_cost_events_project_date` ON `project_cost_events` (`project_id`, `event_date`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_cost_events_ref` ON `project_cost_events` (`project_id`, `ref_type`, `ref_id`);
--> statement-breakpoint
-- ── 5. project_revenue_events ──
CREATE TABLE IF NOT EXISTS `project_revenue_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`event_date` datetime NOT NULL,
`revenue_type` enum('direct_revenue','subscription','saved_cost','asset_value_add','refund','other') NOT NULL,
`amount` double NOT NULL,
`data_source` enum('api_pulled','manual','calculated','mock') NOT NULL,
`ref_type` varchar(50) NULL,
`ref_id` varchar(200) NULL,
`channel` varchar(50) NULL,
`notes` text NULL,
`created_by` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_revenue_events_project_date` ON `project_revenue_events` (`project_id`, `event_date`);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_revenue_events_ref` ON `project_revenue_events` (`project_id`, `ref_type`, `ref_id`);
--> statement-breakpoint
-- ── 6. project_revenue_mapping ──
CREATE TABLE IF NOT EXISTS `project_revenue_mapping` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`business_project_key` varchar(100) NOT NULL,
`enabled` int NULL DEFAULT 1,
`notes` text NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_revenue_mapping_business_key` ON `project_revenue_mapping` (`business_project_key`);
--> statement-breakpoint
CREATE INDEX `idx_revenue_mapping_project` ON `project_revenue_mapping` (`project_id`);
--> statement-breakpoint
-- ── 7. unmapped_revenue_events ──
CREATE TABLE IF NOT EXISTS `unmapped_revenue_events` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`external_id` varchar(200) NOT NULL,
`business_project_key` varchar(100) NOT NULL,
`event_date` datetime NOT NULL,
`amount` double NOT NULL,
`revenue_type` varchar(50) NULL,
`channel` varchar(50) NULL,
`raw_payload` json NULL,
`status` enum('pending','resolved','ignored') NULL DEFAULT 'pending',
`resolved_event_id` varchar(50) NULL,
`created_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_unmapped_external_id` ON `unmapped_revenue_events` (`external_id`);
--> statement-breakpoint
CREATE INDEX `idx_unmapped_status` ON `unmapped_revenue_events` (`status`);
--> statement-breakpoint
-- ── 8. seed: 4 套默认策略参数 ──
INSERT INTO `roi_strategies` (`id`, `category`, `name`, `formula_key`, `params`, `updated_at`) VALUES
('strat-cash-cow', 'cash_cow', '现金牛', 'cash_cow', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-efficiency-tool', 'efficiency_tool', '效能工具', 'efficiency_tool', '{"hourlyRate":400,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-moat', 'moat', '资本护城河', 'moat', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW()),
('strat-composite', 'composite', '复合型', 'composite', '{"hourlyRate":400,"amortYears":3,"commitHourCoef":0.5,"taskHourCoef":6}', NOW());

View File

@ -1,12 +0,0 @@
-- ROI 引擎 AI 驱动因子文案表(月度快照)
CREATE TABLE IF NOT EXISTS `roi_driver_factors` (
`id` varchar(50) NOT NULL PRIMARY KEY,
`project_id` varchar(50) NOT NULL,
`period_key` varchar(20) NOT NULL,
`factors` json NOT NULL,
`context` json NULL,
`generated_at` datetime NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_driver_factors_project_period` ON `roi_driver_factors` (`project_id`, `period_key`);

View File

@ -1,16 +0,0 @@
-- 业务体系归属字段:bizSystem + projectType + planeIdentifier 备份
ALTER TABLE `projects` ADD COLUMN `biz_system` enum('airhubs','airflow','aircore') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `project_type` enum('hardware','software') NULL;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `plane_identifier` varchar(200) NULL;
--> statement-breakpoint
-- 把现有 identifier 一次性备份到 plane_identifier(以防 AI 覆盖后丢失)
UPDATE `projects` SET `plane_identifier` = `identifier` WHERE `plane_identifier` IS NULL;
--> statement-breakpoint
CREATE INDEX `idx_projects_biz_system` ON `projects` (`biz_system`);
--> statement-breakpoint
CREATE INDEX `idx_projects_project_type` ON `projects` (`project_type`);

View File

@ -22,27 +22,6 @@
"when": 1777430400000, "when": 1777430400000,
"tag": "0002_add_ai_okr_fields", "tag": "0002_add_ai_okr_fields",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1779494400000,
"tag": "0003_add_roi_engine",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1779580800000,
"tag": "0004_add_roi_driver_factors",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1779667200000,
"tag": "0005_add_biz_system",
"breakpoints": true
} }
] ]
} }

View File

@ -1,67 +0,0 @@
/**
* 一次性脚本:对所有项目跑 AI (category + bizSystem + projectType),
* identifier(airhubs-hw-001 )+ mapping
*
* :
* bun run scripts/ai-classify-all.ts #
* bun run scripts/ai-classify-all.ts --force # ()
*/
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects } from '../src/db/schema';
import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester';
import { applyAutoIdentifier } from '../src/services/roi/identifier-generator';
const force = process.argv.includes('--force');
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}, force=${force}`);
let okCount = 0, skipCount = 0, failCount = 0;
const startedAt = Date.now();
for (const p of all) {
const label = `${p.planeIdentifier || p.identifier || '?'} (${p.name})`;
const alreadyFull = p.category && p.bizSystem && p.projectType;
if (!force && alreadyFull) {
console.log(` ⊘ SKIP ${label} — fully classified (${p.bizSystem}/${p.projectType}/${p.category})`);
skipCount += 1;
continue;
}
try {
console.log(` → AI ${label} ...`);
const sug = await suggestProjectTag(p.id);
const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate();
const needsAsset = sug.suggestedCategory === 'moat';
const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null);
// 1. 更新分类字段
await db.update(projects).set({
category: sug.suggestedCategory,
launchedAt: launchedAt as any,
vAsset: vAsset,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
// 2. 自动生成新 identifier + 同步 mapping
const newId = await applyAutoIdentifier(p.id, sug.suggestedBizSystem, sug.suggestedProjectType);
console.log(`${newId} | ${sug.suggestedBizSystem}/${sug.suggestedProjectType}/${sug.suggestedCategory} conf=${sug.confidence}`);
console.log(`${sug.reasoning.slice(0, 80)}`);
okCount += 1;
} catch (e) {
console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`);
failCount += 1;
}
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log('');
console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`);
process.exit(0);

View File

@ -1,60 +0,0 @@
/**
* 一次性脚本: AI + category
* ()
* - 默认立项日:取项目 created_at() today-90
* - V_asset,AI moat 100,000(,)
* 用法: bun run scripts/ai-tag-all.ts [--force]
*/
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects } from '../src/db/schema';
import { suggestProjectTag } from '../src/services/roi/ai-tag-suggester';
const force = process.argv.includes('--force');
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}, force=${force}`);
let okCount = 0, skipCount = 0, failCount = 0;
const startedAt = Date.now();
for (const p of all) {
const label = `${p.identifier || '?'} (${p.name})`;
if (!force && p.category) {
console.log(` ⊘ SKIP ${label} — already tagged as ${p.category}`);
skipCount += 1;
continue;
}
try {
console.log(` → AI ${label} ...`);
const sug = await suggestProjectTag(p.id);
const launchedAt = p.launchedAt ?? p.createdAt ?? dayjs().subtract(90, 'day').toDate();
// 护城河默认 V_asset 占位
const needsAsset = sug.suggestedCategory === 'moat';
const vAsset = needsAsset ? (p.vAsset ?? 100_000) : (p.vAsset ?? null);
await db.update(projects).set({
category: sug.suggestedCategory,
launchedAt: launchedAt as any,
vAsset: vAsset,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
console.log(`${sug.suggestedCategory} (conf=${sug.confidence}) — ${sug.reasoning.slice(0, 60)}`);
okCount += 1;
} catch (e) {
console.error(` ✗ FAIL ${label}: ${(e as Error).message.slice(0, 200)}`);
failCount += 1;
}
// 1 秒间隔避免 LLM 限流
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log('');
console.log(`Done. ok=${okCount} skipped=${skipCount} failed=${failCount} elapsed=${elapsed}s`);
process.exit(0);

View File

@ -1,15 +0,0 @@
/**
* 一次性脚本:回填过去 N cost_events( commits/tasks)
* 用法: bun run scripts/backfill-cost-events.ts [days=60]
*/
import dayjs from 'dayjs';
import { runCostEventIngest } from '../src/services/roi/cost-ingest';
const days = Number(process.argv[2] || 60);
const from = dayjs().subtract(days, 'day').startOf('day').toDate();
const to = dayjs().endOf('day').toDate();
console.log(`Backfilling cost events from ${from.toISOString()} to ${to.toISOString()}...`);
await runCostEventIngest({ from, to });
console.log('Done.');
process.exit(0);

View File

@ -1,66 +0,0 @@
/**
* 一次性脚本:把所有项目的 launchedAt commit
* repo / repo commit , 2026-01-01
*
* 用法:bun run scripts/backfill-launched-at.ts
*/
import { asc, eq, inArray } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects, projectRepos, gitCommits } from '../src/db/schema';
const DEFAULT_DATE = new Date('2026-01-01T00:00:00+08:00');
/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */
function normalizeRepoName(raw: string): string {
let cleaned = raw.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try {
const parts = new URL(cleaned).pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || cleaned;
} catch { /* fallthrough */ }
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}
const all = await db.select().from(projects);
console.log(`Total projects: ${all.length}`);
let withCommitsCount = 0, fallbackCount = 0;
for (const p of all) {
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, p.id));
const repoNames = repos.map(r => normalizeRepoName(r.repoName));
let launchedAt = DEFAULT_DATE;
let source = 'default-2026-01-01';
if (repoNames.length > 0) {
const earliest = await db.select({ committedAt: gitCommits.committedAt, repoName: gitCommits.repoName, sha: gitCommits.sha })
.from(gitCommits)
.where(inArray(gitCommits.repoName, repoNames))
.orderBy(asc(gitCommits.committedAt))
.limit(1);
if (earliest.length > 0 && earliest[0].committedAt) {
launchedAt = earliest[0].committedAt instanceof Date ? earliest[0].committedAt : new Date(earliest[0].committedAt);
source = `first commit ${earliest[0].repoName}/${earliest[0].sha?.slice(0, 7)}`;
withCommitsCount += 1;
} else {
fallbackCount += 1;
}
} else {
fallbackCount += 1;
}
await db.update(projects).set({
launchedAt,
updatedAt: new Date(),
}).where(eq(projects.id, p.id));
const label = `${p.identifier || p.id} (${p.name})`;
console.log(` ${label.padEnd(50)}${launchedAt.toISOString().slice(0, 10)} [${source}]`);
}
console.log('');
console.log(`Done. with-commits=${withCommitsCount} fallback=${fallbackCount}`);
process.exit(0);

View File

@ -1,44 +0,0 @@
/**
* 一次性脚本: mock API N
* seed project_revenue_mapping(identifier businessProjectKey)
* 用法: bun run scripts/backfill-revenue.ts [days=60]
*/
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { eq, inArray } from 'drizzle-orm';
import { db } from '../src/db/index';
import { projects, projectRevenueMapping } from '../src/db/schema';
import { runRevenueIngest } from '../src/services/roi/revenue-ingest';
const days = Number(process.argv[2] || 60);
// 1. seed mapping: identifier → projectId(没有就建)
const all = await db.select().from(projects);
const existing = await db.select().from(projectRevenueMapping);
const existingKeys = new Set(existing.map(m => m.businessProjectKey));
const toInsert = all
.filter(p => p.identifier && !existingKeys.has(p.identifier))
.map(p => ({
id: uuid(),
projectId: p.id,
businessProjectKey: p.identifier!,
enabled: 1,
notes: 'auto-seeded',
createdAt: new Date(),
updatedAt: new Date(),
}));
if (toInsert.length > 0) {
await db.insert(projectRevenueMapping).values(toInsert);
console.log(`Seeded ${toInsert.length} project mappings`);
}
// 2. 逐日拉 mock 数据
for (let i = days; i >= 0; i--) {
const date = dayjs().subtract(i, 'day').format('YYYY-MM-DD');
await runRevenueIngest(date);
if (i % 10 === 0) console.log(`${date}`);
}
console.log('Done.');
process.exit(0);

View File

@ -1,12 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
// z.coerce.boolean() 把任何非空字符串当 true(包括"false"),用 preprocess 修正
const envBool = (defaultValue: boolean) =>
z.preprocess((v) => {
if (typeof v !== 'string') return v;
return ['true', '1', 'yes', 'on'].includes(v.toLowerCase());
}, z.boolean()).default(defaultValue);
const envSchema = z.object({ const envSchema = z.object({
JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'), JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
PORT: z.coerce.number().default(3200), PORT: z.coerce.number().default(3200),
@ -32,15 +25,10 @@ const envSchema = z.object({
ADMIN_PASSWORD: z.string().min(6).default('Admin123!'), ADMIN_PASSWORD: z.string().min(6).default('Admin123!'),
// AI (豆包 Doubao / 火山引擎 Ark) // AI (豆包 Doubao / 火山引擎 Ark)
AI_ENABLED: envBool(false), AI_ENABLED: z.coerce.boolean().default(false),
AI_API_KEY: z.string().default(''), AI_API_KEY: z.string().default(''),
AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'), AI_MODEL: z.string().default('doubao-seed-2-0-pro-260215'),
AI_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'), AI_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'),
// ROI 外部营收 API
MOCK_REVENUE_API: envBool(false),
REVENUE_API_BASE_URL: z.string().default('http://localhost:3200/mock'),
REVENUE_API_KEY: z.string().default('mock-dev-key-12345'),
}); });
function loadConfig() { function loadConfig() {

View File

@ -35,21 +35,10 @@ export const projects = mysqlTable('projects', {
name: varchar('name', { length: 200 }).notNull(), name: varchar('name', { length: 200 }).notNull(),
identifier: varchar('identifier', { length: 200 }), identifier: varchar('identifier', { length: 200 }),
lastSyncedAt: datetime('last_synced_at'), lastSyncedAt: datetime('last_synced_at'),
// ── ROI 引擎字段 ──
category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']),
compositeStrategies: json('composite_strategies'), // 复合型时勾选的子策略列表
bizSystem: mysqlEnum('biz_system', ['airhubs', 'airflow', 'aircore']), // 三大业务体系归属
projectType: mysqlEnum('project_type', ['hardware', 'software']), // 硬件/软件
planeIdentifier: varchar('plane_identifier', { length: 200 }), // 原始 Plane identifier 备份
ownerId: varchar('owner_id', { length: 50 }).references(() => users.id),
tags: json('tags'),
launchedAt: datetime('launched_at'), // 立项日 — 累计 ROI 起算点
vAsset: double('v_asset'), // 资本护城河的技术资产估值(元)
createdAt: datetime('created_at').notNull(), createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(), updatedAt: datetime('updated_at').notNull(),
}, (table) => ({ }, (table) => ({
planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId), planeProjectIdx: uniqueIndex('uniq_projects_plane_id').on(table.planeProjectId),
categoryIdx: index('idx_projects_category').on(table.category),
})); }));
// ── Sprint Snapshots ── // ── Sprint Snapshots ──
@ -247,7 +236,7 @@ export const userProjectPermissions = mysqlTable('user_project_permissions', {
// ── Sync Logs ── // ── Sync Logs ──
export const syncLogs = mysqlTable('sync_logs', { export const syncLogs = mysqlTable('sync_logs', {
id: varchar('id', { length: 50 }).primaryKey(), id: varchar('id', { length: 50 }).primaryKey(),
source: mysqlEnum('source', ['plane', 'gitea', 'ai_okr', 'roi_cost_ingest', 'roi_revenue_ingest', 'roi_amortizer', 'roi_ai_driver']).notNull(), source: mysqlEnum('source', ['plane', 'gitea', 'ai_okr']).notNull(),
status: mysqlEnum('status', ['success', 'error']).notNull(), status: mysqlEnum('status', ['success', 'error']).notNull(),
message: text('message'), message: text('message'),
recordsProcessed: int('records_processed').default(0), recordsProcessed: int('records_processed').default(0),
@ -263,103 +252,3 @@ export const aiAnalyzedCommits = mysqlTable('ai_analyzed_commits', {
}, (table) => ({ }, (table) => ({
shaIdx: uniqueIndex('uniq_analyzed_sha').on(table.commitSha), shaIdx: uniqueIndex('uniq_analyzed_sha').on(table.commitSha),
})); }));
// ───────────────────────────────────────────────────────────
// ROI 动态规则引擎(v2.0) — 事件流模型
// ───────────────────────────────────────────────────────────
// ── ROI 策略配置库(全局参数,4 分类各一行) ──
export const roiStrategies = mysqlTable('roi_strategies', {
id: varchar('id', { length: 50 }).primaryKey(),
category: mysqlEnum('category', ['cash_cow', 'efficiency_tool', 'moat', 'composite']).notNull(),
name: varchar('name', { length: 200 }).notNull(),
formulaKey: varchar('formula_key', { length: 100 }).notNull(),
// params 例: { hourlyRate: 400, amortYears: 3, commitHourCoef: 0.5, taskHourCoef: 6 }
params: json('params').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
categoryIdx: uniqueIndex('uniq_roi_strategy_category').on(table.category),
}));
// ── 项目成本事件流 ──
export const projectCostEvents = mysqlTable('project_cost_events', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
eventDate: datetime('event_date').notNull(), // 营收发生的自然日(精确到日)
costType: mysqlEnum('cost_type', ['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']).notNull(),
amount: double('amount').notNull(), // 已折算成 CNY 的金额
hours: double('hours'), // 工时(仅 cost_type=dev_hours 时填,辅助溯源)
hourlyRateUsed: double('hourly_rate_used'), // 计算时使用的 R_h 快照
dataSource: mysqlEnum('data_source', ['auto_commits', 'auto_tasks', 'plane_actual', 'manual', 'amortization_cron']).notNull(),
refType: varchar('ref_type', { length: 50 }), // 'git_commit' | 'plane_task' | 'manual'
refId: varchar('ref_id', { length: 200 }), // 关联唯一 id(防重)
notes: text('notes'),
createdBy: varchar('created_by', { length: 50 }).references(() => users.id),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
projectDateIdx: index('idx_cost_events_project_date').on(table.projectId, table.eventDate),
refUniqIdx: uniqueIndex('uniq_cost_events_ref').on(table.projectId, table.refType, table.refId),
}));
// ── 项目产出事件流 ──
export const projectRevenueEvents = mysqlTable('project_revenue_events', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
eventDate: datetime('event_date').notNull(),
revenueType: mysqlEnum('revenue_type', ['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']).notNull(),
amount: double('amount').notNull(), // 可负数(退款/冲账)
dataSource: mysqlEnum('data_source', ['api_pulled', 'manual', 'calculated', 'mock']).notNull(),
refType: varchar('ref_type', { length: 50 }),
refId: varchar('ref_id', { length: 200 }),
channel: varchar('channel', { length: 50 }), // 渠道:alipay/wechat/stripe 等
notes: text('notes'),
createdBy: varchar('created_by', { length: 50 }).references(() => users.id),
createdAt: datetime('created_at').notNull(),
}, (table) => ({
projectDateIdx: index('idx_revenue_events_project_date').on(table.projectId, table.eventDate),
refUniqIdx: uniqueIndex('uniq_revenue_events_ref').on(table.projectId, table.refType, table.refId),
}));
// ── 业务系统 → DevPerf 项目映射 ──
export const projectRevenueMapping = mysqlTable('project_revenue_mapping', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(),
enabled: int('enabled').default(1),
notes: text('notes'),
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
}, (table) => ({
businessKeyIdx: uniqueIndex('uniq_revenue_mapping_business_key').on(table.businessProjectKey),
projectIdx: index('idx_revenue_mapping_project').on(table.projectId),
}));
// ── AI 生成的价值驱动因子文案(月度快照) ──
export const roiDriverFactors = mysqlTable('roi_driver_factors', {
id: varchar('id', { length: 50 }).primaryKey(),
projectId: varchar('project_id', { length: 50 }).references(() => projects.id).notNull(),
periodKey: varchar('period_key', { length: 20 }).notNull(), // YYYY-MM (上月) 或 'lifetime'
factors: json('factors').notNull(), // [{type, text}]
context: json('context'), // 当时的 ROI 数值快照
generatedAt: datetime('generated_at').notNull(),
}, (table) => ({
projectPeriodIdx: uniqueIndex('uniq_driver_factors_project_period').on(table.projectId, table.periodKey),
}));
// ── 未映射的营收事件(收容表) ──
export const unmappedRevenueEvents = mysqlTable('unmapped_revenue_events', {
id: varchar('id', { length: 50 }).primaryKey(),
externalId: varchar('external_id', { length: 200 }).notNull(),
businessProjectKey: varchar('business_project_key', { length: 100 }).notNull(),
eventDate: datetime('event_date').notNull(),
amount: double('amount').notNull(),
revenueType: varchar('revenue_type', { length: 50 }),
channel: varchar('channel', { length: 50 }),
rawPayload: json('raw_payload'),
status: mysqlEnum('status', ['pending', 'resolved', 'ignored']).default('pending'),
resolvedEventId: varchar('resolved_event_id', { length: 50 }), // 解决后关联到 revenue_events.id
createdAt: datetime('created_at').notNull(),
}, (table) => ({
externalIdx: uniqueIndex('uniq_unmapped_external_id').on(table.externalId),
statusIdx: index('idx_unmapped_status').on(table.status),
}));

View File

@ -11,8 +11,6 @@ import { memberRoutes } from './routes/members';
import { okrRoutes } from './routes/okr'; import { okrRoutes } from './routes/okr';
import { gitRoutes } from './routes/git'; import { gitRoutes } from './routes/git';
import { adminRoutes } from './routes/admin'; import { adminRoutes } from './routes/admin';
import { mockRevenueRoutes } from './routes/mock-revenue';
import { roiRoutes } from './routes/roi';
// Importing db triggers auto-migration on first load (B-07 fix) // Importing db triggers auto-migration on first load (B-07 fix)
import { db } from './db/index'; import { db } from './db/index';
import { seedAdminUser } from './db/seed-auto'; import { seedAdminUser } from './db/seed-auto';
@ -46,12 +44,6 @@ app.get('/api/health', (c) => {
// Auth routes (public) // Auth routes (public)
app.route('/api/auth', authRoutes); app.route('/api/auth', authRoutes);
// Mock 营收 API (开发期,挂在 /mock 避开 /api/* 的 JWT auth)
if (config.MOCK_REVENUE_API) {
app.route('/mock', mockRevenueRoutes);
console.info('[Mock] Revenue API mock mounted at /mock/revenue/*');
}
// Protected routes // Protected routes
app.use('/api/*', authMiddleware); app.use('/api/*', authMiddleware);
app.route('/api', overviewRoutes); app.route('/api', overviewRoutes);
@ -60,7 +52,6 @@ app.route('/api', memberRoutes);
app.route('/api', okrRoutes); app.route('/api', okrRoutes);
app.route('/api', gitRoutes); app.route('/api', gitRoutes);
app.route('/api', adminRoutes); app.route('/api', adminRoutes);
app.route('/api', roiRoutes);
// Error handler // Error handler
app.onError(errorHandler); app.onError(errorHandler);

View File

@ -1,55 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import { AppError } from './error-handler';
/**
* AI
* - perUserPerMinute: 每用户每分钟最多 N
* - perProjectPerDay: 每项目每天最多 M ( query/param projectId)
*
* ,(,)
*/
interface Counter {
count: number;
windowStart: number;
}
const userMinuteCounters = new Map<string, Counter>();
const projectDayCounters = new Map<string, Counter>();
function tick(map: Map<string, Counter>, key: string, windowMs: number, limit: number): boolean {
const now = Date.now();
const c = map.get(key);
if (!c || now - c.windowStart >= windowMs) {
map.set(key, { count: 1, windowStart: now });
return true;
}
if (c.count >= limit) return false;
c.count += 1;
return true;
}
export function aiRateLimit(opts: {
perUserPerMinute?: number;
perProjectPerDay?: number;
projectIdParam?: string; // 哪个 param 是 projectId,默认 'id'
} = {}): MiddlewareHandler {
const perUser = opts.perUserPerMinute ?? 5;
const perProject = opts.perProjectPerDay ?? 20;
const projectParam = opts.projectIdParam ?? 'id';
return async (c, next) => {
const user = c.get('user');
if (!user) throw new AppError(40101, 'Authentication required', 401);
if (!tick(userMinuteCounters, user.sub, 60_000, perUser)) {
throw new AppError(42901, `AI 调用过于频繁,每分钟最多 ${perUser}`, 429);
}
const projectId = c.req.param(projectParam);
if (projectId) {
if (!tick(projectDayCounters, projectId, 24 * 3600_000, perProject)) {
throw new AppError(42902, `该项目今日 AI 调用已达上限 ${perProject}`, 429);
}
}
await next();
};
}

View File

@ -1,35 +0,0 @@
import { eq } from 'drizzle-orm';
import { db } from '../db/index';
import { projects, userProjectPermissions } from '../db/schema';
import type { JWTPayload } from './auth';
/**
* 写权限: admin ; owner
* (, userProjectPermissions can_write )
*/
export async function hasProjectWriteAccess(user: JWTPayload, projectId: string): Promise<boolean> {
if (user.role === 'admin') return true;
const [project] = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
return project?.ownerId === user.sub;
}
/**
* 读权限: admin/manager ;developer/viewer owner userProjectPermissions
*/
export async function hasProjectReadAccess(user: JWTPayload, projectId: string): Promise<boolean> {
if (user.role === 'admin' || user.role === 'manager') return true;
const [project] = await db.select({ ownerId: projects.ownerId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (project?.ownerId === user.sub) return true;
const perm = await db.select().from(userProjectPermissions)
.where(eq(userProjectPermissions.userId, user.sub))
.limit(50);
return perm.some(p => p.projectId === projectId);
}

View File

@ -125,10 +125,6 @@ adminRoutes.get('/admin/projects', async (c) => {
id: p.id, id: p.id,
name: p.name, name: p.name,
identifier: p.identifier, identifier: p.identifier,
planeIdentifier: p.planeIdentifier || null,
bizSystem: p.bizSystem || null,
projectType: p.projectType || null,
category: p.category || null,
planeProjectId: p.planeProjectId, planeProjectId: p.planeProjectId,
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt, createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
})), })),

View File

@ -1,51 +0,0 @@
import { Hono } from 'hono';
import { config } from '../config';
import { generateMockRevenueForDate, listMockBusinessProjects } from '../services/roi/revenue-ingest/mock-generator';
/**
* Mock API,"附录 A:外部营收 API 接入规范"
* MOCK_REVENUE_API=true , /mock( /api/* , JWT auth)
*
* :
* GET /mock/revenue/daily?date=YYYY-MM-DD
* GET /mock/revenue/projects
*/
export const mockRevenueRoutes = new Hono();
mockRevenueRoutes.use('*', async (c, next) => {
// 鉴权:严格按附录 A 的 Bearer Token
const auth = c.req.header('Authorization') || '';
const match = auth.match(/^Bearer\s+(.+)$/i);
if (!match || match[1] !== config.REVENUE_API_KEY) {
return c.json({ error: 'UNAUTHORIZED' }, 401);
}
await next();
});
mockRevenueRoutes.get('/revenue/daily', async (c) => {
const date = c.req.query('date');
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return c.json({ error: 'INVALID_DATE', message: 'date must be YYYY-MM-DD' }, 400);
}
try {
const events = await generateMockRevenueForDate(date);
return c.json({
date,
events,
nextCursor: null, // mock 不分页(数据量小)
totalCount: events.length,
});
} catch (e) {
return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500);
}
});
mockRevenueRoutes.get('/revenue/projects', async (c) => {
try {
const projects = await listMockBusinessProjects();
return c.json({ projects, totalCount: projects.length });
} catch (e) {
return c.json({ error: 'INTERNAL', message: (e as Error).message }, 500);
}
});

View File

@ -67,35 +67,14 @@ overviewRoutes.get('/overview', async (c) => {
allowedObjIds = new Set(objs.map(o => o.id)); allowedObjIds = new Set(objs.map(o => o.id));
} }
// ─── 性能优化:一次性批量拉取,内存里做 join,避免 N+1 ─── // 1. 各项目 OKR 整体进度(替代 Sprint 交付率)
const [allProjects, allObjectivesRaw, allKRs, allUsersData] = await Promise.all([ const allProjects = await db.select().from(projects);
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 }[] = []; 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 = objectivesByProject.get(proj.id) || []; let projObjectives = await db.select().from(objectives)
.where(eq(objectives.projectId, proj.id));
if (period) { if (period) {
projObjectives = projObjectives.filter(o => o.period === period); projObjectives = projObjectives.filter(o => o.period === period);
} }
@ -111,11 +90,14 @@ 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(
allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id) (await db.select().from(objectives))
.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));
} }
@ -143,17 +125,16 @@ 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();
const [commitsRaw, prsRaw] = await Promise.all([ let commits = await db.select().from(gitCommits)
db.select().from(gitCommits).where(gte(gitCommits.committedAt, twelveWeeksAgo)), .where(gte(gitCommits.committedAt, twelveWeeksAgo));
db.select().from(gitPRs).where(gte(gitPRs.createdAt, twelveWeeksAgo)), let prs = await db.select().from(gitPRs)
]); .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 = allUsersData; // 复用上面已 fetch 的 const allUsers = await db.select().from(users);
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++) {
@ -189,19 +170,23 @@ overviewRoutes.get('/overview', async (c) => {
})), })),
}; };
// 5. OKR Progress(用前面已 fetch 的 allObjectivesRaw + krsByObjective + usersById) // 5. OKR Progress
let allObjectives = period let allObjectives = period
? allObjectivesRaw.filter(o => o.period === period) ? await db.select().from(objectives).where(eq(objectives.period, period))
: allObjectivesRaw; : await db.select().from(objectives);
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 = allObjectives.map(obj => { const okrProgress = [];
const krs = krsByObjective.get(obj.id) || []; for (const obj of allObjectives) {
const owner = obj.ownerId ? usersById.get(obj.ownerId) : null; const krs = await db.select().from(keyResults)
return { .where(eq(keyResults.objectiveId, obj.id));
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 || '未指定',
@ -214,16 +199,17 @@ 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 = allKRs; // 复用前面 fetch let allKRsRaw = await db.select().from(keyResults);
const allObjsMap = objectivesById; const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
// 观察者:过滤到已分配项目的 KR
if (allowedObjIds) { if (allowedObjIds) {
allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId)); allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId));
} }
@ -245,8 +231,12 @@ 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 ? usersById.get(obj.ownerId) : null; const owner = obj?.ownerId
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null; ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const endDate = kr.endDate || ''; const endDate = kr.endDate || '';
const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day')); const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day'));
@ -298,24 +288,22 @@ 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 ? usersById.get(obj.ownerId) : null; const owner = obj?.ownerId
const proj = obj?.projectId ? projectsById.get(obj.projectId) : null; ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
const reason = lastLogByKr.get(kr.id) || ''; : null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
// 获取最后一条操作日志作为原因
const lastLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(1);
const reason = lastLog[0]?.detail || '';
let itemStatus: string; let itemStatus: string;
let statusLabel: string; let statusLabel: string;

View File

@ -26,11 +26,7 @@ projectRoutes.get('/projects', async (c) => {
id: p.id, id: p.id,
name: p.name, name: p.name,
identifier: p.identifier, identifier: p.identifier,
planeIdentifier: p.planeIdentifier || null,
planeProjectId: p.planeProjectId, planeProjectId: p.planeProjectId,
category: p.category || null,
bizSystem: p.bizSystem || null,
projectType: p.projectType || null,
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt, createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
lastSyncedAt: p.lastSyncedAt?.toISOString() || null, lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
})), })),
@ -367,15 +363,7 @@ projectRoutes.get('/projects/:id', async (c) => {
id: project.id, id: project.id,
name: project.name, name: project.name,
identifier: project.identifier, identifier: project.identifier,
planeIdentifier: project.planeIdentifier || null,
lastSyncedAt: project.lastSyncedAt?.toISOString() || null, lastSyncedAt: project.lastSyncedAt?.toISOString() || null,
category: project.category || null,
compositeStrategies: project.compositeStrategies || null,
bizSystem: project.bizSystem || null,
projectType: project.projectType || null,
launchedAt: project.launchedAt instanceof Date ? project.launchedAt.toISOString() : project.launchedAt,
vAsset: project.vAsset ?? null,
ownerId: project.ownerId || null,
}, },
currentCycle, currentCycle,
milestones: milestoneData, milestones: milestoneData,

View File

@ -1,475 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { v4 as uuid } from 'uuid';
import { and, desc, eq, gte, lte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../db/index';
import {
projects, projectCostEvents, projectRevenueEvents,
roiStrategies, projectRevenueMapping, unmappedRevenueEvents,
roiDriverFactors,
} from '../db/schema';
import { requireRole } from '../middleware/role';
import { AppError } from '../middleware/error-handler';
import { hasProjectReadAccess, hasProjectWriteAccess } from '../middleware/project-access';
import { aiRateLimit } from '../middleware/ai-rate-limit';
import { getAllowedProjectIds } from '../services/permissions';
import { aggregate, aggregateMany } from '../services/roi/aggregator';
import { timeseries, type Granularity } from '../services/roi/timeseries';
import { invalidateStrategyCache } from '../services/roi/strategy-params';
import { suggestProjectTag } from '../services/roi/ai-tag-suggester';
import { applyAutoIdentifier } from '../services/roi/identifier-generator';
export const roiRoutes = new Hono();
// ✱ 全局权限:所有 ROI 相关端点(含 /api/projects/:id/{tag,cost-events,...})只允许 admin
roiRoutes.use('*', requireRole('admin'));
// ──────────────────────────────────────────
// 核心查询接口
// ──────────────────────────────────────────
const aggregateQuerySchema = z.object({
projectId: z.string().min(1),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
// GET /api/roi/aggregate?projectId=&from=&to=
roiRoutes.get('/roi/aggregate', zValidator('query', aggregateQuerySchema), async (c) => {
const user = c.get('user');
const { projectId, from, to } = c.req.valid('query');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const result = await aggregate(projectId, fromDate, toDate);
return c.json({ code: 0, data: result, message: 'success' });
});
const timeseriesQuerySchema = aggregateQuerySchema.extend({
granularity: z.enum(['day', 'week', 'month', 'year']).default('month'),
});
// GET /api/roi/timeseries?projectId=&from=&to=&granularity=
roiRoutes.get('/roi/timeseries', zValidator('query', timeseriesQuerySchema), async (c) => {
const user = c.get('user');
const { projectId, from, to, granularity } = c.req.valid('query');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const buckets = await timeseries(projectId, fromDate, toDate, granularity as Granularity);
return c.json({ code: 0, data: buckets, message: 'success' });
});
const dashboardQuerySchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
// GET /api/roi/dashboard?from=&to= — 全公司汇总(自动按权限过滤项目)
roiRoutes.get('/roi/dashboard',
requireRole('admin', 'manager'),
zValidator('query', dashboardQuerySchema),
async (c) => {
const user = c.get('user');
const { from, to } = c.req.valid('query');
const allowedIds = await getAllowedProjectIds(user); // admin/manager => null
let allProjects = await db.select().from(projects);
if (allowedIds !== null) {
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
}
const projectIds = allProjects.map(p => p.id);
const fromDate = new Date(from + 'T00:00:00+08:00');
const toDate = new Date(to + 'T23:59:59+08:00');
const results = await aggregateMany(projectIds, fromDate, toDate);
// 按 category 分组汇总(给堆叠图用)
const byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }> = {};
let totalCost = 0, totalRevenue = 0;
const projectMap = new Map(allProjects.map(p => [p.id, p]));
const projectCards = results.map(r => {
const p = projectMap.get(r.projectId);
const cat = p?.category || 'uncategorized';
if (!byCategory[cat]) byCategory[cat] = { totalCost: 0, totalRevenue: 0, netProfit: 0, projectCount: 0 };
byCategory[cat].totalCost += r.totalCost;
byCategory[cat].totalRevenue += r.totalRevenue;
byCategory[cat].netProfit += r.netProfit;
byCategory[cat].projectCount += 1;
totalCost += r.totalCost;
totalRevenue += r.totalRevenue;
return {
projectId: r.projectId,
name: p?.name || '',
identifier: p?.identifier || '',
category: p?.category || null,
totalCost: r.totalCost,
totalRevenue: r.totalRevenue,
roiValue: r.roiValue,
confidence: r.confidence,
};
});
const companyRoi = totalCost > 0 ? Math.round((totalRevenue - totalCost) / totalCost * 10000) / 100 : null;
return c.json({
code: 0,
data: {
from, to,
summary: {
totalCost: Math.round(totalCost * 100) / 100,
totalRevenue: Math.round(totalRevenue * 100) / 100,
netProfit: Math.round((totalRevenue - totalCost) * 100) / 100,
roiValue: companyRoi,
projectCount: projectIds.length,
},
byCategory,
projects: projectCards,
},
message: 'success',
});
});
// ──────────────────────────────────────────
// 事件流写入/查询
// ──────────────────────────────────────────
const createCostEventSchema = z.object({
eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
costType: z.enum(['dev_hours', 'hardware_bom', 'service_fee', 'amortization', 'other']),
amount: z.number().min(0).max(1e8),
hours: z.number().min(0).optional().nullable(),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/projects/:id/cost-events
roiRoutes.post('/projects/:id/cost-events',
zValidator('json', createCostEventSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
const id = uuid();
await db.insert(projectCostEvents).values({
id,
projectId,
eventDate: new Date(body.eventDate + 'T00:00:00+08:00'),
costType: body.costType,
amount: body.amount,
hours: body.hours ?? null,
hourlyRateUsed: null,
dataSource: 'manual',
refType: 'manual',
refId: id, // 手动事件用自己的 id 当 refId,保证唯一
notes: body.notes ?? null,
createdBy: user.sub,
createdAt: new Date(),
});
return c.json({ code: 0, data: { id }, message: 'success' });
});
const createRevenueEventSchema = z.object({
eventDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
revenueType: z.enum(['direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other']),
amount: z.number().min(-1e8).max(1e8),
channel: z.string().max(50).optional().nullable(),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/projects/:id/revenue-events
roiRoutes.post('/projects/:id/revenue-events',
zValidator('json', createRevenueEventSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
const id = uuid();
await db.insert(projectRevenueEvents).values({
id,
projectId,
eventDate: new Date(body.eventDate + 'T00:00:00+08:00'),
revenueType: body.revenueType,
amount: body.amount,
dataSource: 'manual',
refType: 'manual',
refId: id,
channel: body.channel ?? null,
notes: body.notes ?? null,
createdBy: user.sub,
createdAt: new Date(),
});
return c.json({ code: 0, data: { id }, message: 'success' });
});
const listEventsQuerySchema = z.object({
type: z.enum(['cost', 'revenue']),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
limit: z.coerce.number().min(1).max(500).default(100),
});
// GET /api/projects/:id/events?type=cost|revenue&from=&to=
roiRoutes.get('/projects/:id/events', zValidator('query', listEventsQuerySchema), async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const { type, from, to, limit } = c.req.valid('query');
let fromDate = from ? new Date(from + 'T00:00:00+08:00') : dayjs().subtract(90, 'day').toDate();
const toDate = to ? new Date(to + 'T23:59:59+08:00') : new Date();
// 截断到 launchedAt(若有),跟 aggregate/timeseries 对齐 —— 事件流只显示算入 ROI 的事件
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (fromDate < launchedAt) fromDate = launchedAt;
}
if (type === 'cost') {
const rows = await db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, fromDate),
lte(projectCostEvents.eventDate, toDate)
)).orderBy(desc(projectCostEvents.eventDate)).limit(limit);
return c.json({ code: 0, data: rows, message: 'success' });
} else {
const rows = await db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, fromDate),
lte(projectRevenueEvents.eventDate, toDate)
)).orderBy(desc(projectRevenueEvents.eventDate)).limit(limit);
return c.json({ code: 0, data: rows, message: 'success' });
}
});
// DELETE /api/projects/:id/events/:eventId
roiRoutes.delete('/projects/:id/events/:eventId', async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
const eventId = c.req.param('eventId');
const type = c.req.query('type');
if (type !== 'cost' && type !== 'revenue') {
throw new AppError(40001, 'type query must be cost or revenue', 400);
}
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
if (type === 'cost') {
await db.delete(projectCostEvents).where(and(
eq(projectCostEvents.id, eventId),
eq(projectCostEvents.projectId, projectId)
));
} else {
await db.delete(projectRevenueEvents).where(and(
eq(projectRevenueEvents.id, eventId),
eq(projectRevenueEvents.projectId, projectId)
));
}
return c.json({ code: 0, data: null, message: 'success' });
});
// ──────────────────────────────────────────
// 策略配置
// ──────────────────────────────────────────
// GET /api/roi/strategies
roiRoutes.get('/roi/strategies', async (c) => {
const rows = await db.select().from(roiStrategies);
return c.json({ code: 0, data: rows, message: 'success' });
});
const patchStrategySchema = z.object({
params: z.object({
hourlyRate: z.number().min(0).max(10000).optional(),
amortYears: z.number().min(1).max(20).optional(),
commitHourCoef: z.number().min(0).max(40).optional(),
taskHourCoef: z.number().min(0).max(80).optional(),
}),
});
// PATCH /api/roi/strategies/:id
roiRoutes.patch('/roi/strategies/:id',
requireRole('admin'),
zValidator('json', patchStrategySchema),
async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const [existing] = await db.select().from(roiStrategies).where(eq(roiStrategies.id, id)).limit(1);
if (!existing) throw new AppError(40401, 'Strategy not found', 404);
const merged = { ...(existing.params as object), ...body.params };
await db.update(roiStrategies).set({
params: merged,
updatedAt: new Date(),
}).where(eq(roiStrategies.id, id));
invalidateStrategyCache();
return c.json({ code: 0, data: null, message: 'success' });
});
// ──────────────────────────────────────────
// 项目打标
// ──────────────────────────────────────────
const tagProjectSchema = z.object({
category: z.enum(['cash_cow', 'efficiency_tool', 'moat', 'composite']),
compositeStrategies: z.array(z.enum(['cash_cow', 'efficiency_tool', 'moat'])).optional().nullable(),
bizSystem: z.enum(['airhubs', 'airflow', 'aircore']).optional().nullable(),
projectType: z.enum(['hardware', 'software']).optional().nullable(),
ownerId: z.string().optional().nullable(),
launchedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
vAsset: z.number().min(0).max(1e10).optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
});
// POST /api/projects/:id/tag
roiRoutes.post('/projects/:id/tag',
zValidator('json', tagProjectSchema),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
const body = c.req.valid('json');
// 复合型 + 包含 moat,或者 category=moat,vAsset 必填
const needsAsset = body.category === 'moat' ||
(body.category === 'composite' && (body.compositeStrategies || []).includes('moat'));
if (needsAsset && (!body.vAsset || body.vAsset <= 0)) {
throw new AppError(40002, '资本护城河项目必须填写 vAsset (技术资产估值)', 422);
}
await db.update(projects).set({
category: body.category,
compositeStrategies: body.compositeStrategies ?? null,
ownerId: body.ownerId ?? null,
launchedAt: body.launchedAt ? new Date(body.launchedAt + 'T00:00:00+08:00') : null,
vAsset: body.vAsset ?? null,
tags: body.tags ?? null,
updatedAt: new Date(),
}).where(eq(projects.id, projectId));
// 若同时给出 bizSystem + projectType,自动生成新 identifier
let newIdentifier: string | null = null;
if (body.bizSystem && body.projectType) {
newIdentifier = await applyAutoIdentifier(projectId, body.bizSystem, body.projectType);
}
return c.json({ code: 0, data: { identifier: newIdentifier }, message: 'success' });
});
// ──────────────────────────────────────────
// 项目映射(业务方 key → DevPerf project)
// ──────────────────────────────────────────
// GET /api/roi/mapping
roiRoutes.get('/roi/mapping', requireRole('admin', 'manager'), async (c) => {
const rows = await db.select().from(projectRevenueMapping);
return c.json({ code: 0, data: rows, message: 'success' });
});
const createMappingSchema = z.object({
projectId: z.string().min(1),
businessProjectKey: z.string().min(1).max(100),
enabled: z.boolean().default(true),
notes: z.string().max(500).optional().nullable(),
});
// POST /api/roi/mapping
roiRoutes.post('/roi/mapping',
requireRole('admin'),
zValidator('json', createMappingSchema),
async (c) => {
const body = c.req.valid('json');
const now = new Date();
await db.insert(projectRevenueMapping).values({
id: uuid(),
projectId: body.projectId,
businessProjectKey: body.businessProjectKey,
enabled: body.enabled ? 1 : 0,
notes: body.notes ?? null,
createdAt: now,
updatedAt: now,
});
return c.json({ code: 0, data: null, message: 'success' });
});
// DELETE /api/roi/mapping/:id
roiRoutes.delete('/roi/mapping/:id', requireRole('admin'), async (c) => {
const id = c.req.param('id');
await db.delete(projectRevenueMapping).where(eq(projectRevenueMapping.id, id));
return c.json({ code: 0, data: null, message: 'success' });
});
// GET /api/roi/unmapped — 列出未映射的营收(管理员处理)
roiRoutes.get('/roi/unmapped', requireRole('admin', 'manager'), async (c) => {
const rows = await db.select().from(unmappedRevenueEvents)
.where(eq(unmappedRevenueEvents.status, 'pending'))
.orderBy(desc(unmappedRevenueEvents.createdAt))
.limit(200);
return c.json({ code: 0, data: rows, message: 'success' });
});
// ──────────────────────────────────────────
// AI
// ──────────────────────────────────────────
// POST /api/projects/:id/suggest-tag — AI 推荐项目分类
roiRoutes.post('/projects/:id/suggest-tag',
requireRole('admin', 'manager'),
aiRateLimit({ perUserPerMinute: 5, perProjectPerDay: 20 }),
async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectWriteAccess(user, projectId))) {
throw new AppError(40103, 'No write access to project', 403);
}
try {
const result = await suggestProjectTag(projectId);
return c.json({ code: 0, data: result, message: 'success' });
} catch (e) {
const msg = (e as Error).message;
throw new AppError(50003, `AI 推荐失败: ${msg.slice(0, 200)}`, 502);
}
});
// GET /api/projects/:id/driver-factors?periodKey=YYYY-MM — 查询某月驱动因子文案
roiRoutes.get('/projects/:id/driver-factors', async (c) => {
const user = c.get('user');
const projectId = c.req.param('id');
if (!(await hasProjectReadAccess(user, projectId))) {
throw new AppError(40103, 'No access to project', 403);
}
const periodKey = c.req.query('periodKey');
let rows;
if (periodKey) {
rows = await db.select().from(roiDriverFactors).where(and(
eq(roiDriverFactors.projectId, projectId),
eq(roiDriverFactors.periodKey, periodKey)
));
} else {
// 不传则返回最近 3 个月
rows = await db.select().from(roiDriverFactors)
.where(eq(roiDriverFactors.projectId, projectId))
.orderBy(desc(roiDriverFactors.generatedAt))
.limit(3);
}
return c.json({ code: 0, data: rows, message: 'success' });
});

View File

@ -1,4 +1,4 @@
import { eq, inArray } from 'drizzle-orm'; import { eq } 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,56 +6,24 @@ 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);
if (allObjectives.length === 0) return { objectives: [] }; const result = [];
for (const obj of allObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
const objIds = allObjectives.map(o => o.id); const owner = obj.ownerId
const ownerIds = Array.from(new Set(allObjectives.map(o => o.ownerId).filter(Boolean) as string[])); ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
const projectIds = Array.from(new Set(allObjectives.map(o => o.projectId).filter(Boolean) as string[])); : null;
// 2. 一次性批量拉 KRs / users / projects const project = obj.projectId
const [allKRs, allOwners, allProjects] = await Promise.all([ ? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)), : null;
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([]),
]);
// 3. 一次性批量拉所有 KR 的 logs(只取 postponed 类型,减少传输) result.push({
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 || '未指定',
@ -65,8 +33,15 @@ 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: krs.map(kr => { keyResults: await Promise.all(krs.map(async 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,
@ -75,7 +50,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: lastPostponeReason !== null, wasPostponed,
lastPostponeReason, lastPostponeReason,
startDate: kr.startDate || null, startDate: kr.startDate || null,
endDate: kr.endDate || null, endDate: kr.endDate || null,
@ -83,9 +58,9 @@ export async function getOKRByPeriod(period?: string) {
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0, : 0,
}; };
}), })),
}; });
}); }
return { objectives: result }; return { objectives: result };
} }

View File

@ -1,150 +0,0 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { db } from '../../db/index';
import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema';
import { calculateBep } from './bep-calculator';
import { evaluateConfidence } from './confidence-evaluator';
import type { AggregateResult, CostBreakdown, RevenueBreakdown } from './types';
function toDate(input: string | Date): Date {
return input instanceof Date ? input : new Date(input);
}
function toIsoDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const ZERO_COST: CostBreakdown = { devHours: 0, hardwareBom: 0, serviceFee: 0, amortization: 0, other: 0 };
const ZERO_REVENUE: RevenueBreakdown = { directRevenue: 0, subscription: 0, savedCost: 0, assetValueAdd: 0, refund: 0, other: 0 };
/**
* [from, to] , ROI
* - from/to projects.launchedAt launchedAt
* - TotalCost = 0 roiValue null
*/
export async function aggregate(
projectId: string,
from: string | Date,
to: string | Date
): Promise<AggregateResult> {
let fromDate = toDate(from);
const toDateObj = toDate(to);
// 截断到 launchedAt
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (fromDate < launchedAt) fromDate = launchedAt;
}
// 拉取窗口内的事件
const costEvents = await db.select().from(projectCostEvents).where(
and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, fromDate),
lte(projectCostEvents.eventDate, toDateObj)
)
);
const revenueEvents = await db.select().from(projectRevenueEvents).where(
and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, fromDate),
lte(projectRevenueEvents.eventDate, toDateObj)
)
);
// 聚合
const costBreakdown: CostBreakdown = { ...ZERO_COST };
let totalCost = 0;
for (const e of costEvents) {
totalCost += e.amount;
switch (e.costType) {
case 'dev_hours': costBreakdown.devHours += e.amount; break;
case 'hardware_bom': costBreakdown.hardwareBom += e.amount; break;
case 'service_fee': costBreakdown.serviceFee += e.amount; break;
case 'amortization': costBreakdown.amortization += e.amount; break;
default: costBreakdown.other += e.amount;
}
}
const revenueBreakdown: RevenueBreakdown = { ...ZERO_REVENUE };
let totalRevenue = 0;
for (const e of revenueEvents) {
totalRevenue += e.amount;
switch (e.revenueType) {
case 'direct_revenue': revenueBreakdown.directRevenue += e.amount; break;
case 'subscription': revenueBreakdown.subscription += e.amount; break;
case 'saved_cost': revenueBreakdown.savedCost += e.amount; break;
case 'asset_value_add': revenueBreakdown.assetValueAdd += e.amount; break;
case 'refund': revenueBreakdown.refund += e.amount; break;
default: revenueBreakdown.other += e.amount;
}
}
const netProfit = totalRevenue - totalCost;
const roiValue = totalCost > 0 ? (netProfit / totalCost) * 100 : null;
const confidence = evaluateConfidence(costEvents, revenueEvents);
// BEP 只在累计窗口(from = launchedAt)有意义,且 roi < 100% 时计算
const isLifetimeWindow = project?.launchedAt && Math.abs(fromDate.getTime() - new Date(project.launchedAt).getTime()) < 24 * 3600 * 1000;
let bepDays: number | null = null;
if (isLifetimeWindow && roiValue !== null) {
bepDays = await calculateBep(projectId, totalCost, totalRevenue, toDateObj);
}
return {
projectId,
from: toIsoDay(fromDate),
to: toIsoDay(toDateObj),
totalCost: round2(totalCost),
totalRevenue: round2(totalRevenue),
netProfit: round2(netProfit),
roiValue: roiValue === null ? null : round2(roiValue),
confidence,
bepDays,
costBreakdown: {
devHours: round2(costBreakdown.devHours),
hardwareBom: round2(costBreakdown.hardwareBom),
serviceFee: round2(costBreakdown.serviceFee),
amortization: round2(costBreakdown.amortization),
other: round2(costBreakdown.other),
},
revenueBreakdown: {
directRevenue: round2(revenueBreakdown.directRevenue),
subscription: round2(revenueBreakdown.subscription),
savedCost: round2(revenueBreakdown.savedCost),
assetValueAdd: round2(revenueBreakdown.assetValueAdd),
refund: round2(revenueBreakdown.refund),
other: round2(revenueBreakdown.other),
},
costEventCount: costEvents.length,
revenueEventCount: revenueEvents.length,
};
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* ()
* , DB
*/
export async function aggregateMany(
projectIds: string[],
from: string | Date,
to: string | Date,
concurrency = 8
): Promise<AggregateResult[]> {
const results: AggregateResult[] = [];
for (let i = 0; i < projectIds.length; i += concurrency) {
const batch = projectIds.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(id => aggregate(id, from, to)));
results.push(...batchResults);
}
return results;
}

View File

@ -1,167 +0,0 @@
import { v4 as uuid } from 'uuid';
import { and, desc, eq, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import {
projects, projectRepos, gitCommits, objectives,
roiDriverFactors, syncLogs,
} from '../../db/schema';
import { callLLM, parseLLMJson } from '../llm-client';
import { aggregate } from './aggregator';
export interface DriverFactor {
type: '现金流驱动' | '降本增效驱动' | '技术资产驱动';
text: string;
}
const SYSTEM_PROMPT = `你是软件项目价值分析师。给定项目本月 ROI 数据和近期工作内容,生成 1-3 条"价值驱动因子"文案,告诉管理者这个项目的价值来源是什么。
:
- 现金流驱动: 项目直接产生营收,
- 降本增效驱动: 项目通过工具化/
- 技术资产驱动: 项目沉淀了技术能力//,
1-3 JSON :
{
"factors": [
{ "type": "现金流驱动" | "降本增效驱动" | "技术资产驱动", "text": "60字内的简短说明" }
]
}
text 60 ,,使`;
const ALLOWED_TYPES = new Set(['现金流驱动', '降本增效驱动', '技术资产驱动']);
/**
* "上月", roi_driver_factors
* periodKey = YYYY-MM
*/
export async function generateDriverFactorsForProject(projectId: string, asOf: Date = new Date()): Promise<DriverFactor[]> {
const lastMonth = dayjs(asOf).subtract(1, 'month');
const periodKey = lastMonth.format('YYYY-MM');
// 上月 ROI
const monthStart = lastMonth.startOf('month').toDate();
const monthEnd = lastMonth.endOf('month').toDate();
const monthAgg = await aggregate(projectId, monthStart, monthEnd);
// 累计 ROI(从 launchedAt 起)
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
const launchedAt = project.launchedAt ? new Date(project.launchedAt) : monthStart;
const lifetimeAgg = await aggregate(projectId, launchedAt, monthEnd);
// 近期 commits 摘要
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId));
const repoNames = new Set(repos.map(r => r.repoName));
let commitSummary = '(无近期提交)';
if (repoNames.size > 0) {
const recent = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, monthStart))
.orderBy(desc(gitCommits.committedAt))
.limit(30);
const projCommits = recent.filter(c => repoNames.has(c.repoName));
if (projCommits.length > 0) {
commitSummary = projCommits
.map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`)
.slice(0, 15)
.join('\n');
}
}
// OKR 进展
const objs = await db.select().from(objectives).where(eq(objectives.projectId, projectId)).limit(5);
const okrSummary = objs.length > 0
? objs.map(o => `- ${o.title} (进度 ${Math.round((o.progress || 0) * 100)}%)`).join('\n')
: '(无 OKR)';
const userPrompt = `项目: ${project.name} (定位: ${project.category || '未打标'})
ROI (${periodKey}):
- : ¥${monthAgg.totalCost.toLocaleString()}
- : ¥${monthAgg.totalRevenue.toLocaleString()}
- ROI: ${monthAgg.roiValue === null ? 'N/A' : monthAgg.roiValue + '%'}
- : ¥${monthAgg.revenueBreakdown.directRevenue}
- : ¥${monthAgg.revenueBreakdown.savedCost}
- : ¥${monthAgg.revenueBreakdown.assetValueAdd}
ROI (): ${lifetimeAgg.roiValue === null ? 'N/A' : lifetimeAgg.roiValue + '%'}
commits:
${commitSummary}
OKR :
${okrSummary}
JSON `;
const raw = await callLLM(SYSTEM_PROMPT, userPrompt);
const parsed = parseLLMJson<{ factors: DriverFactor[] }>(raw);
// 校验
if (!Array.isArray(parsed.factors)) throw new Error('LLM response missing factors array');
const validFactors = parsed.factors
.filter(f => f && ALLOWED_TYPES.has(f.type) && typeof f.text === 'string')
.map(f => ({ type: f.type, text: f.text.slice(0, 80) }))
.slice(0, 3);
if (validFactors.length === 0) throw new Error('LLM returned no valid factors');
// upsert: 先 delete 旧的(同 project + period),再 insert 新的
await db.delete(roiDriverFactors).where(and(
eq(roiDriverFactors.projectId, projectId),
eq(roiDriverFactors.periodKey, periodKey),
));
await db.insert(roiDriverFactors).values({
id: uuid(),
projectId,
periodKey,
factors: validFactors,
context: {
monthRoi: monthAgg.roiValue,
lifetimeRoi: lifetimeAgg.roiValue,
monthCost: monthAgg.totalCost,
monthRevenue: monthAgg.totalRevenue,
},
generatedAt: new Date(),
});
return validFactors;
}
/**
* cron:为所有打标项目生成驱动因子
*/
export async function runMonthlyDriverFactorsGeneration(): Promise<void> {
const startedAt = Date.now();
let okCount = 0, failCount = 0;
const errors: string[] = [];
// 仅为已打标的项目生成
const allProjects = await db.select().from(projects);
const candidates = allProjects.filter(p => p.category !== null);
for (const p of candidates) {
try {
await generateDriverFactorsForProject(p.id);
okCount += 1;
} catch (e) {
failCount += 1;
const msg = `${p.identifier || p.id}: ${(e as Error).message}`;
errors.push(msg);
console.warn('[ROI-AI-DRIVER]', msg);
}
// 简单速率控制:每项目间隔 1 秒,避免 LLM 限流
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_ai_driver',
status: failCount === 0 ? 'success' : 'error',
message: `driver factors: ok=${okCount} fail=${failCount} elapsed=${elapsed}ms${errors.length > 0 ? ' errors=' + errors.slice(0, 3).join('|') : ''}`,
recordsProcessed: okCount,
syncedAt: new Date(),
}).catch(() => {});
}

View File

@ -1,116 +0,0 @@
import { and, desc, eq, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import {
projects, gitCommits, projectRepos, objectives,
} from '../../db/schema';
import { callLLM, parseLLMJson } from '../llm-client';
import type { RoiCategory } from './types';
import type { BizSystem, ProjectType } from './identifier-generator';
export interface TagSuggestion {
suggestedCategory: RoiCategory;
suggestedBizSystem: BizSystem;
suggestedProjectType: ProjectType;
confidence: number; // 0~1
reasoning: string;
}
const SYSTEM_PROMPT = `你是软件项目分析师。基于项目名称、近期 commits、OKR,从 3 个独立维度判断项目归属。
1:ROI category
- cash_cow(): /,SaaS API
- efficiency_tool(): /,, CI/CD
- moat(): ,
- composite(): 2 3 ,
2:业务体系 bizSystem()
- airhubs: 硬件与潮玩业务线(ToB/ToC ) AI
- airflow: 内容生成与效能线() OKR DevOpsIAM
- aircore: 底层技术基座() RTC AR/Live2D AI
3:技术属性 projectType
- hardware: 项目核心产物含硬件(PCB)
- software: 纯软件, Web/App//SDK/
JSON ,:
{
"suggestedCategory": "cash_cow" | "efficiency_tool" | "moat" | "composite",
"suggestedBizSystem": "airhubs" | "airflow" | "aircore",
"suggestedProjectType": "hardware" | "software",
"confidence": 0.0~1.0 ,,
"reasoning": "简短中文说明(100 字内),先说 bizSystem 归属,再说 category 和 projectType 依据"
}`;
/**
* AI
* 上下文: 项目名 + identifier + 30 commit messages + OKR title
*/
export async function suggestProjectTag(projectId: string): Promise<TagSuggestion> {
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
// 近 30 天 commits
const since = dayjs().subtract(30, 'day').toDate();
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId));
const repoNames = new Set(repos.map(r => r.repoName));
let commitSummary = '';
if (repoNames.size > 0) {
const recent = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, since))
.orderBy(desc(gitCommits.committedAt))
.limit(50);
const projCommits = recent.filter(c => repoNames.has(c.repoName));
commitSummary = projCommits
.map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`)
.slice(0, 25)
.join('\n') || '(无近期 commits)';
} else {
commitSummary = '(项目未绑定 repo)';
}
// 关联 OKR
const objs = await db.select({ title: objectives.title })
.from(objectives)
.where(eq(objectives.projectId, projectId))
.limit(10);
const okrSummary = objs.length > 0
? objs.map(o => `- ${o.title}`).join('\n')
: '(无 OKR)';
const userPrompt = `项目信息:
名称: ${project.name}
代号: ${project.identifier || '(无)'}
30 commit messages:
${commitSummary}
OKR :
${okrSummary}
JSON `;
const raw = await callLLM(SYSTEM_PROMPT, userPrompt);
const parsed = parseLLMJson<TagSuggestion>(raw);
// 校验
const validCategories: RoiCategory[] = ['cash_cow', 'efficiency_tool', 'moat', 'composite'];
const validBizSystems: BizSystem[] = ['airhubs', 'airflow', 'aircore'];
const validTypes: ProjectType[] = ['hardware', 'software'];
if (!validCategories.includes(parsed.suggestedCategory)) {
throw new Error(`Invalid category from LLM: ${parsed.suggestedCategory}`);
}
if (!validBizSystems.includes(parsed.suggestedBizSystem)) {
throw new Error(`Invalid bizSystem from LLM: ${parsed.suggestedBizSystem}`);
}
if (!validTypes.includes(parsed.suggestedProjectType)) {
throw new Error(`Invalid projectType from LLM: ${parsed.suggestedProjectType}`);
}
if (typeof parsed.confidence !== 'number' || parsed.confidence < 0 || parsed.confidence > 1) {
parsed.confidence = 0.5;
}
parsed.reasoning = (parsed.reasoning || '').slice(0, 250);
return parsed;
}

View File

@ -1,60 +0,0 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { db } from '../../db/index';
import { projectCostEvents, projectRevenueEvents } from '../../db/schema';
/**
* BEP ()
*
* :
* - 0: 已经回本( ROI >= 100%)
* - null: 30 <= 0,
* - 正整数: 预计还需多少天回本()
*/
export function bepFromTotals(
totalCost: number,
totalRevenue: number,
recentCost: number,
recentRevenue: number,
windowDays = 30
): number | null {
const deficit = totalCost - totalRevenue;
if (deficit <= 0) return 0;
const dailyNetIncome = (recentRevenue - recentCost) / windowDays;
if (dailyNetIncome <= 0) return null;
return Math.ceil(deficit / dailyNetIncome);
}
/**
* (BEP), DB
*/
export async function calculateBep(
projectId: string,
totalCost: number,
totalRevenue: number,
asOf: Date
): Promise<number | null> {
if (totalCost - totalRevenue <= 0) return 0; // 已回本,免去 DB 查询
const since = new Date(asOf);
since.setDate(since.getDate() - 30);
const [costAgg, revenueAgg] = await Promise.all([
db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, since),
lte(projectCostEvents.eventDate, asOf)
)),
db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, since),
lte(projectRevenueEvents.eventDate, asOf)
)),
]);
const recentCost = costAgg.reduce((sum, e) => sum + e.amount, 0);
const recentRevenue = revenueAgg.reduce((sum, e) => sum + e.amount, 0);
return bepFromTotals(totalCost, totalRevenue, recentCost, recentRevenue, 30);
}

View File

@ -1,34 +0,0 @@
import type { Confidence } from './types';
type CostEvent = { dataSource: string };
type RevenueEvent = { dataSource: string };
const HIGH_QUALITY_COST_SOURCES = new Set(['plane_actual', 'manual']);
/**
* :
* - High: 成本事件 80%+ plane_actual / manual,
* - Low: 只有成本无收益, auto_commits/auto_tasks
* - Medium: 其他情况
*/
export function evaluateConfidence(
costEvents: CostEvent[],
revenueEvents: RevenueEvent[]
): Confidence {
const totalCost = costEvents.length;
const totalRevenue = revenueEvents.length;
if (totalCost === 0 && totalRevenue === 0) return 'low';
// 无收益 = 直接 low(无法判断盈亏)
if (totalRevenue === 0) return 'low';
// 全自动估算 = low
const highQualityCount = costEvents.filter(e => HIGH_QUALITY_COST_SOURCES.has(e.dataSource)).length;
if (totalCost > 0 && highQualityCount === 0) return 'low';
// 80%+ 高质量 = high
if (totalCost > 0 && highQualityCount / totalCost >= 0.8) return 'high';
return 'medium';
}

View File

@ -1,129 +0,0 @@
import { v4 as uuid } from 'uuid';
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { gitCommits, projectRepos, projects, projectCostEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
import type { RoiCategory } from '../types';
interface IngestResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* [from, to] git commits cost_events
* project_repos repoName projectId
* (project_id, ref_type='git_commit', ref_id=sha)
*/
export async function ingestCommitsAsCostEvents(from: Date, to: Date): Promise<IngestResult> {
const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 1. 建 repoName → projectId 映射
const bindings = await db.select().from(projectRepos);
if (bindings.length === 0) {
return result; // 没有任何 repo 绑定项目,跳过
}
const repoToProject = new Map<string, string>();
for (const b of bindings) {
repoToProject.set(normalizeRepoName(b.repoName), b.projectId);
}
// 2. 拉项目的 category 字典(为不同 category 取不同 R_h)
const allProjects = await db.select().from(projects);
const projectCategory = new Map<string, RoiCategory | null>(
allProjects.map(p => [p.id, p.category as RoiCategory | null])
);
// 3. 拉时间窗内的 commits
const commits = await db.select().from(gitCommits).where(and(
gte(gitCommits.committedAt, from),
lte(gitCommits.committedAt, to)
));
if (commits.length === 0) return result;
// 4. 反查已 insert 过的 sha(在该项目 ref_id 上),批量过滤
const candidates = commits
.map(c => ({ commit: c, projectId: repoToProject.get(normalizeRepoName(c.repoName)) }))
.filter(x => x.projectId !== undefined) as { commit: typeof commits[0]; projectId: string }[];
if (candidates.length === 0) return result;
// 一次性查所有可能冲突的 (project_id, sha) 组合
const shasByProject = new Map<string, Set<string>>();
for (const { commit, projectId } of candidates) {
if (!shasByProject.has(projectId)) shasByProject.set(projectId, new Set());
shasByProject.get(projectId)!.add(commit.sha);
}
const existingShas = new Set<string>(); // key = projectId + '::' + sha
for (const [projectId, shas] of shasByProject) {
if (shas.size === 0) continue;
const existing = await db.select({
refId: projectCostEvents.refId,
}).from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
eq(projectCostEvents.refType, 'git_commit'),
inArray(projectCostEvents.refId, Array.from(shas))
));
for (const e of existing) {
if (e.refId) existingShas.add(`${projectId}::${e.refId}`);
}
}
// 5. 按项目分组批量插入
const now = new Date();
const toInsert: typeof projectCostEvents.$inferInsert[] = [];
for (const { commit, projectId } of candidates) {
const key = `${projectId}::${commit.sha}`;
if (existingShas.has(key)) {
result.skippedCount += 1;
continue;
}
const params = await getStrategyParams(projectCategory.get(projectId) ?? null);
const hours = params.commitHourCoef;
const amount = hours * params.hourlyRate;
toInsert.push({
id: uuid(),
projectId,
eventDate: commit.committedAt instanceof Date ? commit.committedAt : new Date(commit.committedAt),
costType: 'dev_hours',
amount,
hours,
hourlyRateUsed: params.hourlyRate,
dataSource: 'auto_commits',
refType: 'git_commit',
refId: commit.sha,
notes: (commit.message || '').split('\n')[0].slice(0, 200),
createdBy: null,
createdAt: now,
});
result.projectStats[projectId] = (result.projectStats[projectId] || 0) + 1;
}
if (toInsert.length > 0) {
// 分批插入避免单批过大
const BATCH_SIZE = 200;
for (let i = 0; i < toInsert.length; i += BATCH_SIZE) {
await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE));
}
}
result.insertedCount = toInsert.length;
return result;
}
/** 抹除 .git 后缀和 URL 前缀,只保留仓库名 */
function normalizeRepoName(raw: string): string {
let cleaned = raw.trim().replace(/\.git$/, '');
if (cleaned.includes('://')) {
try {
const parts = new URL(cleaned).pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || cleaned;
} catch { /* fallthrough */ }
}
if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned;
return cleaned;
}

View File

@ -1,104 +0,0 @@
import { v4 as uuid } from 'uuid';
import { and, eq, gte, inArray, lte, isNotNull } from 'drizzle-orm';
import { db } from '../../../db/index';
import { taskSnapshots, projects, projectCostEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
import type { RoiCategory } from '../types';
interface IngestResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* [from, to] Plane tasks cost_events
* task projectId , repo
* (project_id, ref_type='plane_task', ref_id=planeIssueId)
*
* taskSnapshots actual_hours , taskHourCoef ,
* dataSource='auto_tasks', Medium
*/
export async function ingestPlaneTasksAsCostEvents(from: Date, to: Date): Promise<IngestResult> {
const result: IngestResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 1. 拉时间窗内完成的 tasks(只算 completedAt 在窗口内的)
const completedTasks = await db.select().from(taskSnapshots).where(and(
isNotNull(taskSnapshots.completedAt),
isNotNull(taskSnapshots.projectId),
gte(taskSnapshots.completedAt, from),
lte(taskSnapshots.completedAt, to)
));
if (completedTasks.length === 0) return result;
// 2. 项目 category 字典
const allProjects = await db.select().from(projects);
const projectCategory = new Map<string, RoiCategory | null>(
allProjects.map(p => [p.id, p.category as RoiCategory | null])
);
// 3. 检查去重(按 projectId 分组查已存在的 plane_issue_id)
const issuesByProject = new Map<string, Set<string>>();
for (const t of completedTasks) {
if (!t.projectId) continue;
if (!issuesByProject.has(t.projectId)) issuesByProject.set(t.projectId, new Set());
issuesByProject.get(t.projectId)!.add(t.planeIssueId);
}
const existingKeys = new Set<string>();
for (const [projectId, issueIds] of issuesByProject) {
if (issueIds.size === 0) continue;
const existing = await db.select({
refId: projectCostEvents.refId,
}).from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
eq(projectCostEvents.refType, 'plane_task'),
inArray(projectCostEvents.refId, Array.from(issueIds))
));
for (const e of existing) {
if (e.refId) existingKeys.add(`${projectId}::${e.refId}`);
}
}
// 4. 准备 insert
const now = new Date();
const toInsert: typeof projectCostEvents.$inferInsert[] = [];
for (const task of completedTasks) {
if (!task.projectId || !task.completedAt) continue;
const key = `${task.projectId}::${task.planeIssueId}`;
if (existingKeys.has(key)) {
result.skippedCount += 1;
continue;
}
const params = await getStrategyParams(projectCategory.get(task.projectId) ?? null);
const hours = params.taskHourCoef;
const amount = hours * params.hourlyRate;
toInsert.push({
id: uuid(),
projectId: task.projectId,
eventDate: task.completedAt instanceof Date ? task.completedAt : new Date(task.completedAt),
costType: 'dev_hours',
amount,
hours,
hourlyRateUsed: params.hourlyRate,
dataSource: 'auto_tasks',
refType: 'plane_task',
refId: task.planeIssueId,
notes: task.title?.slice(0, 200) || null,
createdBy: null,
createdAt: now,
});
result.projectStats[task.projectId] = (result.projectStats[task.projectId] || 0) + 1;
}
if (toInsert.length > 0) {
const BATCH_SIZE = 200;
for (let i = 0; i < toInsert.length; i += BATCH_SIZE) {
await db.insert(projectCostEvents).values(toInsert.slice(i, i + BATCH_SIZE));
}
}
result.insertedCount = toInsert.length;
return result;
}

View File

@ -1,65 +0,0 @@
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { syncLogs } from '../../../db/schema';
import { ingestCommitsAsCostEvents } from './from-commits';
import { ingestPlaneTasksAsCostEvents } from './from-plane-tasks';
export interface RunOptions {
/** 要 ingest 的时间窗起点(含)。默认为昨天 00:00 */
from?: Date;
/** 要 ingest 的时间窗终点(含)。默认为昨天 23:59:59.999 */
to?: Date;
}
/**
* 主入口: [from, to] ingest cost_events
* commits plane tasks
* , syncLogs
*/
export async function runCostEventIngest(opts: RunOptions = {}): Promise<void> {
const from = opts.from ?? dayjs().subtract(1, 'day').startOf('day').toDate();
const to = opts.to ?? dayjs().subtract(1, 'day').endOf('day').toDate();
const startedAt = Date.now();
let totalInserted = 0;
let totalSkipped = 0;
const errors: string[] = [];
try {
const r1 = await ingestCommitsAsCostEvents(from, to);
totalInserted += r1.insertedCount;
totalSkipped += r1.skippedCount;
console.info(`[ROI-COST-INGEST] commits → cost: inserted=${r1.insertedCount} skipped=${r1.skippedCount}`);
} catch (e) {
const msg = `commits ingest failed: ${(e as Error).message}`;
console.error('[ROI-COST-INGEST]', msg);
errors.push(msg);
}
try {
const r2 = await ingestPlaneTasksAsCostEvents(from, to);
totalInserted += r2.insertedCount;
totalSkipped += r2.skippedCount;
console.info(`[ROI-COST-INGEST] tasks → cost: inserted=${r2.insertedCount} skipped=${r2.skippedCount}`);
} catch (e) {
const msg = `tasks ingest failed: ${(e as Error).message}`;
console.error('[ROI-COST-INGEST]', msg);
errors.push(msg);
}
const elapsed = Date.now() - startedAt;
const status = errors.length === 0 ? 'success' : 'error';
const message = errors.length === 0
? `cost ingest ok: inserted=${totalInserted} skipped=${totalSkipped} from=${dayjs(from).format('YYYY-MM-DD')} to=${dayjs(to).format('YYYY-MM-DD')} elapsed=${elapsed}ms`
: `cost ingest partial: inserted=${totalInserted} skipped=${totalSkipped} errors=${errors.join(' | ')}`;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_cost_ingest',
status,
message,
recordsProcessed: totalInserted,
syncedAt: new Date(),
}).catch(e => console.error('[ROI-COST-INGEST] syncLog write failed:', e));
}

View File

@ -1,86 +0,0 @@
import { and, eq, like } from 'drizzle-orm';
import { db } from '../../db/index';
import { projects, projectRevenueMapping } from '../../db/schema';
export type BizSystem = 'airhubs' | 'airflow' | 'aircore';
export type ProjectType = 'hardware' | 'software';
const TYPE_SHORT: Record<ProjectType, string> = {
hardware: 'hw',
software: 'sw',
};
/**
* bizSystem + projectType identifier
* :`{bizSystem}-{hw|sw}-{3位序号}`, `airhubs-hw-001``airflow-sw-002`
* (bizSystem, projectType)
*/
export async function generateIdentifier(
bizSystem: BizSystem,
projectType: ProjectType
): Promise<string> {
const prefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`;
const existing = await db.select({ identifier: projects.identifier })
.from(projects)
.where(and(
eq(projects.bizSystem, bizSystem),
eq(projects.projectType, projectType),
like(projects.identifier, `${prefix}%`)
));
let maxSeq = 0;
for (const row of existing) {
if (!row.identifier) continue;
const m = row.identifier.match(/-(\d{3})$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxSeq) maxSeq = n;
}
}
const nextSeq = String(maxSeq + 1).padStart(3, '0');
return `${prefix}${nextSeq}`;
}
/**
* identifier , project_revenue_mapping businessProjectKey
* ( mock unmapped)
*
* newIdentifier
*/
export async function applyAutoIdentifier(
projectId: string,
bizSystem: BizSystem,
projectType: ProjectType
): Promise<string> {
// 已经有合规 identifier 时跳过
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
const expectedPrefix = `${bizSystem}-${TYPE_SHORT[projectType]}-`;
if (project.identifier?.startsWith(expectedPrefix) &&
project.bizSystem === bizSystem &&
project.projectType === projectType) {
return project.identifier; // 已是正确格式
}
const newId = await generateIdentifier(bizSystem, projectType);
const oldId = project.identifier;
await db.update(projects).set({
identifier: newId,
bizSystem,
projectType,
updatedAt: new Date(),
}).where(eq(projects.id, projectId));
// 同步更新 mapping(若存在)
if (oldId) {
await db.update(projectRevenueMapping).set({
businessProjectKey: newId,
updatedAt: new Date(),
}).where(eq(projectRevenueMapping.businessProjectKey, oldId));
}
return newId;
}

View File

@ -1,90 +0,0 @@
import { v4 as uuid } from 'uuid';
import { and, eq, isNotNull } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { projects, projectRevenueEvents } from '../../../db/schema';
import { getStrategyParams } from '../strategy-params';
export interface AmortizeResult {
insertedCount: number;
skippedCount: number;
projectStats: Record<string, number>;
}
/**
* 1 cron 调用:为所有 [] [ moat] insert
*
* :
* amount = V_asset / (N × 12)
* eventDate =
* refType = 'asset_amortization', refId = `${projectId}-${yyyy-MM}`
*
* 触发时机假设:在每月 1 cron ,asOf =
*/
export async function runAssetAmortization(asOf: Date = new Date()): Promise<AmortizeResult> {
const result: AmortizeResult = { insertedCount: 0, skippedCount: 0, projectStats: {} };
// 摊销期间 = 上月
const lastMonth = dayjs(asOf).subtract(1, 'month');
const periodKey = lastMonth.format('YYYY-MM');
const eventDate = lastMonth.endOf('month').toDate();
// 1. 找出所有资本护城河项目(category=moat 且 v_asset>0)
const moatProjects = await db.select().from(projects).where(and(
eq(projects.category, 'moat'),
isNotNull(projects.vAsset)
));
// 复合型包含 moat 也算入
const compositeProjects = await db.select().from(projects).where(and(
eq(projects.category, 'composite'),
isNotNull(projects.vAsset)
));
const compositeWithMoat = compositeProjects.filter(p => {
const strategies = (p.compositeStrategies as string[]) || [];
return Array.isArray(strategies) && strategies.includes('moat');
});
const targets = [...moatProjects, ...compositeWithMoat];
// 2. 对每个项目 insert(refId 唯一索引保证幂等,跑两次只 insert 第一次)
const now = new Date();
for (const project of targets) {
if (!project.vAsset || project.vAsset <= 0) continue;
const params = await getStrategyParams(project.category as any);
const amortYears = params.amortYears ?? 3;
const monthlyAmount = project.vAsset / (amortYears * 12);
const refId = `${project.id}-${periodKey}`;
// 先查是否已存在
const [existing] = await db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, project.id),
eq(projectRevenueEvents.refType, 'asset_amortization'),
eq(projectRevenueEvents.refId, refId)
)).limit(1);
if (existing) {
result.skippedCount += 1;
continue;
}
await db.insert(projectRevenueEvents).values({
id: uuid(),
projectId: project.id,
eventDate,
revenueType: 'asset_value_add',
amount: monthlyAmount,
dataSource: 'calculated',
refType: 'asset_amortization',
refId,
channel: null,
notes: `资产摊销 ${periodKey}: V_asset=${project.vAsset} / (${amortYears}×12) = ${monthlyAmount.toFixed(2)}`,
createdBy: null,
createdAt: now,
});
result.insertedCount += 1;
result.projectStats[project.id] = (result.projectStats[project.id] || 0) + 1;
}
return result;
}

View File

@ -1,215 +0,0 @@
import { v4 as uuid } from 'uuid';
import { eq, inArray } from 'drizzle-orm';
import { db } from '../../../db/index';
import {
projectRevenueEvents,
projectRevenueMapping,
unmappedRevenueEvents,
} from '../../../db/schema';
import { config } from '../../../config';
// 对齐附录 A 的响应类型
interface RemoteEvent {
externalId: string;
businessProjectKey: string;
eventDate: string; // YYYY-MM-DD
amount: number;
currency: string;
revenueType: string; // 字符串,我们映射成 enum
channel?: string;
occurredAt?: string;
metadata?: Record<string, any>;
}
interface RemoteResponse {
date: string;
events: RemoteEvent[];
nextCursor: string | null;
totalCount: number;
}
export interface RevenueIngestResult {
insertedCount: number;
unmappedCount: number;
skippedCount: number;
fetchedCount: number;
errors: string[];
}
const REVENUE_TYPE_WHITELIST = new Set([
'direct_revenue', 'subscription', 'saved_cost', 'asset_value_add', 'refund', 'other',
]);
function normalizeRevenueType(t: string): typeof projectRevenueEvents.$inferInsert['revenueType'] {
if (REVENUE_TYPE_WHITELIST.has(t)) return t as any;
return 'other';
}
/**
* API , revenue_events () unmapped_revenue_events()
*
* :
* - cursor (,mock )
* - 5xx/429 退 3
* - 401/4xx ()
* - refType='revenue_api', refId=externalId
*/
export async function ingestRevenueForDate(dateStr: string): Promise<RevenueIngestResult> {
const result: RevenueIngestResult = {
insertedCount: 0,
unmappedCount: 0,
skippedCount: 0,
fetchedCount: 0,
errors: [],
};
// 1. 拉映射表
const mappings = await db.select().from(projectRevenueMapping);
const businessKeyToProject = new Map<string, string>();
for (const m of mappings) {
if (m.enabled) businessKeyToProject.set(m.businessProjectKey, m.projectId);
}
// 2. 分页拉数据
let cursor: string | null = null;
const allEvents: RemoteEvent[] = [];
while (true) {
let resp: RemoteResponse;
try {
resp = await fetchPageWithRetry(dateStr, cursor);
} catch (e) {
result.errors.push((e as Error).message);
return result;
}
allEvents.push(...resp.events);
if (!resp.nextCursor) break;
cursor = resp.nextCursor;
}
result.fetchedCount = allEvents.length;
if (allEvents.length === 0) return result;
// 3. 检查 externalId 去重
const externalIds = allEvents.map(e => e.externalId);
const existingRefIds = new Set<string>();
// 拉 revenue_events 中已有的 ref_id
const BATCH = 500;
for (let i = 0; i < externalIds.length; i += BATCH) {
const slice = externalIds.slice(i, i + BATCH);
const existing = await db.select({ refId: projectRevenueEvents.refId })
.from(projectRevenueEvents)
.where(inArray(projectRevenueEvents.refId, slice));
for (const e of existing) {
if (e.refId) existingRefIds.add(e.refId);
}
}
// 拉 unmapped_revenue_events 已有的 external_id
const existingUnmapped = new Set<string>();
for (let i = 0; i < externalIds.length; i += BATCH) {
const slice = externalIds.slice(i, i + BATCH);
const existing = await db.select({ externalId: unmappedRevenueEvents.externalId })
.from(unmappedRevenueEvents)
.where(inArray(unmappedRevenueEvents.externalId, slice));
for (const e of existing) {
existingUnmapped.add(e.externalId);
}
}
// 4. 分流插入
const now = new Date();
const toInsertMapped: typeof projectRevenueEvents.$inferInsert[] = [];
const toInsertUnmapped: typeof unmappedRevenueEvents.$inferInsert[] = [];
for (const ev of allEvents) {
if (existingRefIds.has(ev.externalId) || existingUnmapped.has(ev.externalId)) {
result.skippedCount += 1;
continue;
}
const projectId = businessKeyToProject.get(ev.businessProjectKey);
const eventDate = new Date(ev.eventDate + 'T00:00:00+08:00');
if (projectId) {
toInsertMapped.push({
id: uuid(),
projectId,
eventDate,
revenueType: normalizeRevenueType(ev.revenueType),
amount: ev.amount,
dataSource: config.MOCK_REVENUE_API ? 'mock' : 'api_pulled',
refType: 'revenue_api',
refId: ev.externalId,
channel: ev.channel || null,
notes: ev.metadata ? JSON.stringify(ev.metadata).slice(0, 500) : null,
createdBy: null,
createdAt: now,
});
} else {
toInsertUnmapped.push({
id: uuid(),
externalId: ev.externalId,
businessProjectKey: ev.businessProjectKey,
eventDate,
amount: ev.amount,
revenueType: ev.revenueType,
channel: ev.channel || null,
rawPayload: ev as any,
status: 'pending',
resolvedEventId: null,
createdAt: now,
});
}
}
if (toInsertMapped.length > 0) {
for (let i = 0; i < toInsertMapped.length; i += 200) {
await db.insert(projectRevenueEvents).values(toInsertMapped.slice(i, i + 200));
}
result.insertedCount = toInsertMapped.length;
}
if (toInsertUnmapped.length > 0) {
for (let i = 0; i < toInsertUnmapped.length; i += 200) {
await db.insert(unmappedRevenueEvents).values(toInsertUnmapped.slice(i, i + 200));
}
result.unmappedCount = toInsertUnmapped.length;
}
return result;
}
async function fetchPageWithRetry(date: string, cursor: string | null): Promise<RemoteResponse> {
const url = new URL(`${config.REVENUE_API_BASE_URL}/revenue/daily`);
url.searchParams.set('date', date);
if (cursor) url.searchParams.set('cursor', cursor);
const backoff = [10_000, 30_000, 90_000];
let lastErr: Error | null = null;
for (let attempt = 0; attempt <= backoff.length; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${config.REVENUE_API_KEY}`,
'Accept': 'application/json',
},
});
if (res.status === 200) {
return await res.json() as RemoteResponse;
}
if (res.status === 401 || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
const body = await res.text().catch(() => '');
throw new Error(`Revenue API ${res.status}: ${body.slice(0, 200)}`);
}
// 5xx 或 429 → 重试
lastErr = new Error(`Revenue API ${res.status}, retrying...`);
} catch (e) {
lastErr = e as Error;
}
if (attempt < backoff.length) {
await new Promise(r => setTimeout(r, backoff[attempt]));
}
}
throw lastErr ?? new Error('Revenue API fetch failed after retries');
}

View File

@ -1,68 +0,0 @@
import { v4 as uuid } from 'uuid';
import dayjs from 'dayjs';
import { db } from '../../../db/index';
import { syncLogs } from '../../../db/schema';
import { ingestRevenueForDate } from './from-revenue-api';
import { runAssetAmortization } from './asset-amortizer';
/**
* ingest 入口:拉昨日数据, syncLog
*/
export async function runRevenueIngest(dateStr?: string): Promise<void> {
const date = dateStr ?? dayjs().subtract(1, 'day').format('YYYY-MM-DD');
const startedAt = Date.now();
try {
const r = await ingestRevenueForDate(date);
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_revenue_ingest',
status: r.errors.length === 0 ? 'success' : 'error',
message: `revenue ingest date=${date}: fetched=${r.fetchedCount} inserted=${r.insertedCount} unmapped=${r.unmappedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms${r.errors.length > 0 ? ' errors=' + r.errors.join('|') : ''}`,
recordsProcessed: r.insertedCount + r.unmappedCount,
syncedAt: new Date(),
});
} catch (e) {
const msg = (e as Error).message;
console.error('[ROI-REVENUE-INGEST]', msg);
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_revenue_ingest',
status: 'error',
message: `revenue ingest date=${date} failed: ${msg}`,
recordsProcessed: 0,
syncedAt: new Date(),
}).catch(() => {});
}
}
/**
* 1 cron
*/
export async function runMonthlyAmortization(): Promise<void> {
const startedAt = Date.now();
try {
const r = await runAssetAmortization();
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_amortizer',
status: 'success',
message: `amortizer ok: inserted=${r.insertedCount} skipped=${r.skippedCount} elapsed=${elapsed}ms`,
recordsProcessed: r.insertedCount,
syncedAt: new Date(),
});
} catch (e) {
const msg = (e as Error).message;
console.error('[ROI-AMORTIZER]', msg);
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_amortizer',
status: 'error',
message: `amortizer failed: ${msg}`,
recordsProcessed: 0,
syncedAt: new Date(),
}).catch(() => {});
}
}

View File

@ -1,145 +0,0 @@
import { db } from '../../../db/index';
import { projects } from '../../../db/schema';
import type { RoiCategory } from '../types';
export interface MockRevenueEvent {
externalId: string;
businessProjectKey: string;
eventDate: string;
amount: number;
currency: string;
revenueType: 'direct_revenue' | 'subscription' | 'refund' | 'other';
channel: string;
occurredAt: string;
metadata?: Record<string, any>;
}
/** 字符串 hash 转 32-bit 整数(用作种子) */
function hashSeed(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/** 简单 LCG 随机数生成器,可复现 */
function makeRng(seed: number) {
let s = seed || 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const CHANNELS = ['alipay', 'wechat', 'stripe', 'bank', 'offline'] as const;
interface CategoryProfile {
baseMin: number;
baseMax: number;
amountMin: number;
amountMax: number;
}
const PROFILE: Record<string, CategoryProfile> = {
cash_cow: { baseMin: 2, baseMax: 5, amountMin: 1000, amountMax: 10000 },
efficiency_tool: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 },
moat: { baseMin: 0, baseMax: 1, amountMin: 800, amountMax: 4000 },
composite: { baseMin: 1, baseMax: 3, amountMin: 800, amountMax: 5000 },
default: { baseMin: 1, baseMax: 2, amountMin: 500, amountMax: 3000 },
};
function getProfile(cat: RoiCategory | null | undefined): CategoryProfile {
if (!cat) return PROFILE.default;
return PROFILE[cat] ?? PROFILE.default;
}
function isWeekend(date: Date): boolean {
const d = date.getDay();
return d === 0 || d === 6;
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function dateOnly(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function isoAt(d: Date, hour: number, min: number, sec: number): string {
return `${dateOnly(d)}T${pad2(hour)}:${pad2(min)}:${pad2(sec)}+08:00`;
}
/**
* mock
* date
*/
export async function generateMockRevenueForDate(dateStr: string): Promise<MockRevenueEvent[]> {
const date = new Date(dateStr + 'T00:00:00+08:00');
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid date: ${dateStr}`);
}
const allProjects = await db.select().from(projects);
const events: MockRevenueEvent[] = [];
const weekend = isWeekend(date);
const rng = makeRng(hashSeed(dateStr + 'devperf-mock'));
for (const project of allProjects) {
if (!project.identifier) continue; // 没有 identifier 跳过
const profile = getProfile(project.category as RoiCategory | null);
let count = profile.baseMin + Math.floor(rng() * (profile.baseMax - profile.baseMin + 1));
if (weekend) count = Math.ceil(count / 2);
for (let i = 0; i < count; i++) {
const isRefund = rng() < 0.05;
const baseAmount = profile.amountMin + Math.floor(rng() * (profile.amountMax - profile.amountMin));
const amount = isRefund ? -Math.floor(baseAmount * 0.3) : baseAmount;
const revenueType: MockRevenueEvent['revenueType'] = isRefund
? 'refund'
: (rng() < 0.3 ? 'subscription' : 'direct_revenue');
const channel = CHANNELS[Math.floor(rng() * CHANNELS.length)];
const hour = Math.floor(rng() * 24);
const minute = Math.floor(rng() * 60);
const second = Math.floor(rng() * 60);
events.push({
externalId: `MOCK-${dateStr.replace(/-/g, '')}-${project.identifier}-${String(i).padStart(3, '0')}`,
businessProjectKey: project.identifier,
eventDate: dateStr,
amount,
currency: 'CNY',
revenueType,
channel,
occurredAt: isoAt(date, hour, minute, second),
metadata: isRefund ? { kind: 'mock_refund' } : { kind: 'mock_revenue' },
});
}
}
// 按 occurredAt 排序,符合真实接口习惯
events.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
return events;
}
export interface MockBusinessProject {
businessProjectKey: string;
name: string;
active: boolean;
}
/** 返回 mock 业务系统中的"项目清单",用于映射维护页面 */
export async function listMockBusinessProjects(): Promise<MockBusinessProject[]> {
const allProjects = await db.select().from(projects);
return allProjects
.filter(p => p.identifier)
.map(p => ({
businessProjectKey: p.identifier!,
name: p.name,
active: true,
}));
}

View File

@ -1,49 +0,0 @@
import { eq } from 'drizzle-orm';
import { db } from '../../db/index';
import { roiStrategies } from '../../db/schema';
import type { RoiCategory, StrategyParams } from './types';
const DEFAULTS: StrategyParams = {
hourlyRate: 400,
amortYears: 3,
commitHourCoef: 0.5,
taskHourCoef: 6,
};
let cache: Map<RoiCategory, StrategyParams> | null = null;
let cacheLoadedAt = 0;
const CACHE_TTL_MS = 60_000; // 1 分钟缓存,改完策略最多 1 分钟生效
async function loadCache(): Promise<Map<RoiCategory, StrategyParams>> {
const now = Date.now();
if (cache && now - cacheLoadedAt < CACHE_TTL_MS) return cache;
const rows = await db.select().from(roiStrategies);
const map = new Map<RoiCategory, StrategyParams>();
for (const row of rows) {
const p = (row.params as Partial<StrategyParams>) || {};
map.set(row.category, {
hourlyRate: p.hourlyRate ?? DEFAULTS.hourlyRate,
amortYears: p.amortYears ?? DEFAULTS.amortYears,
commitHourCoef: p.commitHourCoef ?? DEFAULTS.commitHourCoef,
taskHourCoef: p.taskHourCoef ?? DEFAULTS.taskHourCoef,
});
}
cache = map;
cacheLoadedAt = now;
return map;
}
/**
* (null) cash_cow
*/
export async function getStrategyParams(category: RoiCategory | null): Promise<StrategyParams> {
const map = await loadCache();
const cat: RoiCategory = category ?? 'cash_cow';
return map.get(cat) ?? DEFAULTS;
}
export function invalidateStrategyCache(): void {
cache = null;
cacheLoadedAt = 0;
}

View File

@ -1,97 +0,0 @@
import { and, eq, gte, lte, asc } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import { projects, projectCostEvents, projectRevenueEvents } from '../../db/schema';
export type Granularity = 'day' | 'week' | 'month' | 'year';
export interface TimeseriesBucket {
bucket: string; // YYYY-MM-DD (day/week) | YYYY-MM (month) | YYYY (year)
cost: number;
revenue: number;
net: number;
cumulativeCost: number;
cumulativeRevenue: number;
cumulativeRoi: number | null;
}
function bucketKey(date: Date, granularity: Granularity): string {
const d = dayjs(date);
switch (granularity) {
case 'day': return d.format('YYYY-MM-DD');
case 'week': return d.startOf('week').format('YYYY-MM-DD'); // 周一日期作为 key
case 'month': return d.format('YYYY-MM');
case 'year': return d.format('YYYY');
}
}
/**
*
* ROI 线
*/
export async function timeseries(
projectId: string,
from: Date,
to: Date,
granularity: Granularity
): Promise<TimeseriesBucket[]> {
// 截断到 launchedAt,跟 aggregate 保持一致
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (project?.launchedAt) {
const launchedAt = project.launchedAt instanceof Date ? project.launchedAt : new Date(project.launchedAt);
if (from < launchedAt) from = launchedAt;
}
const [costEvents, revenueEvents] = await Promise.all([
db.select().from(projectCostEvents).where(and(
eq(projectCostEvents.projectId, projectId),
gte(projectCostEvents.eventDate, from),
lte(projectCostEvents.eventDate, to)
)).orderBy(asc(projectCostEvents.eventDate)),
db.select().from(projectRevenueEvents).where(and(
eq(projectRevenueEvents.projectId, projectId),
gte(projectRevenueEvents.eventDate, from),
lte(projectRevenueEvents.eventDate, to)
)).orderBy(asc(projectRevenueEvents.eventDate)),
]);
// 收集所有桶 key,初始化为 0
const buckets = new Map<string, { cost: number; revenue: number }>();
for (const e of costEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.cost += e.amount;
}
for (const e of revenueEvents) {
const k = bucketKey(toDate(e.eventDate), granularity);
if (!buckets.has(k)) buckets.set(k, { cost: 0, revenue: 0 });
buckets.get(k)!.revenue += e.amount;
}
// 按 key 排序输出
const sortedKeys = Array.from(buckets.keys()).sort();
let cumCost = 0, cumRevenue = 0;
return sortedKeys.map(k => {
const { cost, revenue } = buckets.get(k)!;
cumCost += cost;
cumRevenue += revenue;
return {
bucket: k,
cost: round2(cost),
revenue: round2(revenue),
net: round2(revenue - cost),
cumulativeCost: round2(cumCost),
cumulativeRevenue: round2(cumRevenue),
cumulativeRoi: cumCost > 0 ? round2((cumRevenue - cumCost) / cumCost * 100) : null,
};
});
}
function toDate(v: any): Date {
return v instanceof Date ? v : new Date(v);
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -1,49 +0,0 @@
// ROI 引擎共享类型定义
export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite';
export type CostType = 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other';
export type RevenueType = 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other';
export type Confidence = 'high' | 'medium' | 'low';
export interface CostBreakdown {
devHours: number;
hardwareBom: number;
serviceFee: number;
amortization: number;
other: number;
}
export interface RevenueBreakdown {
directRevenue: number;
subscription: number;
savedCost: number;
assetValueAdd: number;
refund: number;
other: number;
}
export interface AggregateResult {
projectId: string;
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null; // 百分比,80 = 80%。成本为 0 返回 null
confidence: Confidence;
bepDays: number | null; // 已回本 = 0;不可回本 = null;正数 = 预计还需天数
costBreakdown: CostBreakdown;
revenueBreakdown: RevenueBreakdown;
costEventCount: number;
revenueEventCount: number;
}
export interface StrategyParams {
hourlyRate: number;
amortYears?: number;
commitHourCoef: number;
taskHourCoef: number;
}

View File

@ -2,17 +2,11 @@ import { v4 as uuid } from 'uuid';
import { Cron } from 'croner'; import { Cron } from 'croner';
import { syncGitea } from './sync-gitea'; import { syncGitea } from './sync-gitea';
import { analyzeCommitsForOKR } from '../services/okr-ai-sync'; import { analyzeCommitsForOKR } from '../services/okr-ai-sync';
import { runCostEventIngest } from '../services/roi/cost-ingest';
import { runRevenueIngest, runMonthlyAmortization } from '../services/roi/revenue-ingest';
import { runMonthlyDriverFactorsGeneration } from '../services/roi/ai-driver-writer';
import { db } from '../db/index'; import { db } from '../db/index';
import { syncLogs } from '../db/schema'; import { syncLogs } from '../db/schema';
import { config } from '../config'; import { config } from '../config';
let giteaJob: Cron | null = null; let giteaJob: Cron | null = null;
let revenueJob: Cron | null = null;
let amortizerJob: Cron | null = null;
let driverFactorsJob: Cron | null = null;
async function runSyncAndAnalyze() { async function runSyncAndAnalyze() {
await syncGitea(); await syncGitea();
@ -33,12 +27,6 @@ async function runSyncAndAnalyze() {
syncedAt: new Date(), syncedAt: new Date(),
}); });
} }
// ROI 成本事件 ingest:Git/Plane 同步完后跑,挂同一周期
console.info('[SCHEDULER] ROI 成本事件 ingest 开始...');
await runCostEventIngest().catch(e =>
console.error('[SCHEDULER] ROI 成本事件 ingest 失败:', e)
);
} }
export function startScheduler(): void { export function startScheduler(): void {
@ -48,36 +36,7 @@ export function startScheduler(): void {
await runSyncAndAnalyze(); await runSyncAndAnalyze();
}); });
// ROI 营收 ingest:每天 03:00 拉昨日营收
// 仅当 mock 启用 或 REVENUE_API_BASE_URL 不指向本地 mock 时才跑(避免对未挂载的端点请求拿 404)
const revenueApiReady = config.MOCK_REVENUE_API || !config.REVENUE_API_BASE_URL.includes('/mock');
if (revenueApiReady) {
revenueJob = new Cron('0 3 * * *', async () => {
console.info('[SCHEDULER] ROI 营收 ingest 开始...');
await runRevenueIngest().catch(e => console.error('[SCHEDULER] ROI 营收 ingest 失败:', e));
});
} else {
console.info('[SCHEDULER] 跳过营收 ingest cron: MOCK_REVENUE_API=false 且 REVENUE_API_BASE_URL 仍指向 mock');
}
// ROI 资产摊销:每月 1 号 01:00
amortizerJob = new Cron('0 1 1 * *', async () => {
console.info('[SCHEDULER] ROI 资产摊销开始...');
await runMonthlyAmortization().catch(e => console.error('[SCHEDULER] ROI 资产摊销失败:', e));
});
// ROI 驱动因子 AI:每月 1 号 03:00(摊销完后跑,数据齐备)
driverFactorsJob = new Cron('0 3 1 * *', async () => {
if (!config.AI_ENABLED || !config.AI_API_KEY) {
console.warn('[SCHEDULER] 跳过 ROI 驱动因子: AI 未启用');
return;
}
console.info('[SCHEDULER] ROI 驱动因子生成开始...');
await runMonthlyDriverFactorsGeneration().catch(e => console.error('[SCHEDULER] ROI 驱动因子失败:', e));
});
console.info(`[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00 UTC, AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}`); console.info(`[SCHEDULER] Gitea 自动同步已启动(每天 02:00 + 19:00 UTC, AI_ENABLED=${config.AI_ENABLED}, AI_API_KEY length=${config.AI_API_KEY?.length || 0}`);
console.info(`[SCHEDULER] ROI cron 已启动:营收 ingest(每天 03:00) + 资产摊销(每月 1 号 01:00) + AI 驱动因子(每月 1 号 03:00)`);
// 启动时立刻写一条诊断日志(不阻塞) // 启动时立刻写一条诊断日志(不阻塞)
db.insert(syncLogs).values({ db.insert(syncLogs).values({
@ -98,8 +57,5 @@ export function startScheduler(): void {
export function stopScheduler(): void { export function stopScheduler(): void {
giteaJob?.stop(); giteaJob?.stop();
revenueJob?.stop();
amortizerJob?.stop();
driverFactorsJob?.stop();
console.info('[SCHEDULER] 已停止同步任务'); console.info('[SCHEDULER] 已停止同步任务');
} }

View File

@ -1,86 +0,0 @@
/**
* ROI
* DB ;/
*/
import { describe, it, expect } from 'bun:test';
import { evaluateConfidence } from '../../src/services/roi/confidence-evaluator';
import { bepFromTotals } from '../../src/services/roi/bep-calculator';
describe('confidence-evaluator', () => {
it('returns low when no events at all', () => {
expect(evaluateConfidence([], [])).toBe('low');
});
it('returns low when only cost, no revenue', () => {
expect(evaluateConfidence([{ dataSource: 'manual' }], [])).toBe('low');
});
it('returns low when all cost is auto-estimated', () => {
const costs = [
{ dataSource: 'auto_commits' },
{ dataSource: 'auto_tasks' },
];
const revenues = [{ dataSource: 'manual' }];
expect(evaluateConfidence(costs, revenues)).toBe('low');
});
it('returns high when 80%+ cost is plane_actual/manual + revenue exists', () => {
const costs = [
{ dataSource: 'plane_actual' },
{ dataSource: 'plane_actual' },
{ dataSource: 'manual' },
{ dataSource: 'manual' },
{ dataSource: 'auto_commits' }, // 1/5 = 20% 自动,刚好满足 80%+ 高质量
];
const revenues = [{ dataSource: 'api_pulled' }];
expect(evaluateConfidence(costs, revenues)).toBe('high');
});
it('returns medium when high-quality cost ratio is between 1% and 80%', () => {
const costs = [
{ dataSource: 'plane_actual' },
{ dataSource: 'auto_commits' },
{ dataSource: 'auto_commits' },
];
const revenues = [{ dataSource: 'manual' }];
// 1/3 = 33% 高质量,落在 medium 区间
expect(evaluateConfidence(costs, revenues)).toBe('medium');
});
it('returns high when all cost manual', () => {
const costs = [{ dataSource: 'manual' }, { dataSource: 'manual' }];
const revenues = [{ dataSource: 'manual' }];
expect(evaluateConfidence(costs, revenues)).toBe('high');
});
});
describe('bepFromTotals', () => {
it('returns 0 when already broken even (revenue >= cost)', () => {
expect(bepFromTotals(100000, 100000, 0, 0)).toBe(0);
expect(bepFromTotals(100000, 150000, 0, 0)).toBe(0);
});
it('returns null when recent net income is non-positive', () => {
// 累计亏 5w,近 30 天净产出为 0
expect(bepFromTotals(100000, 50000, 30000, 30000)).toBe(null);
// 近 30 天还在亏
expect(bepFromTotals(100000, 50000, 30000, 20000)).toBe(null);
});
it('returns positive days when on track to break even', () => {
// 总投入 100w,总产出 80w => 缺口 20w
// 近 30 天:成本 3w,收入 6w => 日均净 1000 元
// 预计天数 = 200000 / 1000 = 200 天
expect(bepFromTotals(1_000_000, 800_000, 30_000, 60_000)).toBe(200);
});
it('rounds up partial days', () => {
// 缺口 1000,日均净 300 => 3.33 天向上取整 = 4
expect(bepFromTotals(2000, 1000, 0, 9000)).toBe(4); // daily = 9000/30 = 300
});
it('respects custom windowDays', () => {
// 缺口 1000,近 10 天净 1000 => 日均 100 => 10 天回本
expect(bepFromTotals(2000, 1000, 0, 1000, 10)).toBe(10);
});
});

View File

@ -1,139 +0,0 @@
import request from './request';
export type RoiCategory = 'cash_cow' | 'efficiency_tool' | 'moat' | 'composite';
export type BizSystem = 'airhubs' | 'airflow' | 'aircore';
export type ProjectType = 'hardware' | 'software';
export type Confidence = 'high' | 'medium' | 'low';
export interface AggregateResult {
projectId: string;
from: string;
to: string;
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null;
confidence: Confidence;
bepDays: number | null;
costBreakdown: Record<string, number>;
revenueBreakdown: Record<string, number>;
costEventCount: number;
revenueEventCount: number;
}
export interface TimeseriesBucket {
bucket: string;
cost: number;
revenue: number;
net: number;
cumulativeCost: number;
cumulativeRevenue: number;
cumulativeRoi: number | null;
}
export interface DashboardResult {
from: string;
to: string;
summary: {
totalCost: number;
totalRevenue: number;
netProfit: number;
roiValue: number | null;
projectCount: number;
};
byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
projects: Array<{
projectId: string;
name: string;
identifier: string;
category: RoiCategory | null;
totalCost: number;
totalRevenue: number;
roiValue: number | null;
confidence: Confidence;
}>;
}
export interface TagSuggestion {
suggestedCategory: RoiCategory;
suggestedBizSystem: BizSystem;
suggestedProjectType: ProjectType;
confidence: number;
reasoning: string;
}
export interface Strategy {
id: string;
category: RoiCategory;
name: string;
formulaKey: string;
params: {
hourlyRate: number;
amortYears?: number;
commitHourCoef: number;
taskHourCoef: number;
};
updatedAt: string;
}
export interface DriverFactor {
type: '现金流驱动' | '降本增效驱动' | '技术资产驱动';
text: string;
}
// ── 核心聚合 ──
export const aggregateRoi = (projectId: string, from: string, to: string) =>
request.get<{ code: number; data: AggregateResult }>(`/api/roi/aggregate`, { params: { projectId, from, to } });
export const timeseriesRoi = (projectId: string, from: string, to: string, granularity: 'day' | 'week' | 'month' | 'year' = 'month') =>
request.get<{ code: number; data: TimeseriesBucket[] }>(`/api/roi/timeseries`, { params: { projectId, from, to, granularity } });
export const fetchDashboard = (from: string, to: string) =>
request.get<{ code: number; data: DashboardResult }>(`/api/roi/dashboard`, { params: { from, to } });
// ── 事件流 ──
export const createCostEvent = (projectId: string, payload: any) =>
request.post(`/api/projects/${projectId}/cost-events`, payload);
export const createRevenueEvent = (projectId: string, payload: any) =>
request.post(`/api/projects/${projectId}/revenue-events`, payload);
export const listEvents = (projectId: string, type: 'cost' | 'revenue', from?: string, to?: string, limit = 100) =>
request.get(`/api/projects/${projectId}/events`, { params: { type, from, to, limit } });
export const deleteEvent = (projectId: string, eventId: string, type: 'cost' | 'revenue') =>
request.delete(`/api/projects/${projectId}/events/${eventId}`, { params: { type } });
// ── 策略 ──
export const fetchStrategies = () =>
request.get<{ code: number; data: Strategy[] }>(`/api/roi/strategies`);
export const updateStrategy = (id: string, params: any) =>
request.patch(`/api/roi/strategies/${id}`, { params });
// ── 打标 ──
export interface TagPayload {
category: RoiCategory;
compositeStrategies?: ('cash_cow' | 'efficiency_tool' | 'moat')[] | null;
bizSystem?: BizSystem | null;
projectType?: ProjectType | null;
ownerId?: string | null;
launchedAt?: string | null;
vAsset?: number | null;
tags?: string[] | null;
}
export const tagProject = (projectId: string, payload: TagPayload) =>
request.post(`/api/projects/${projectId}/tag`, payload);
export const suggestTag = (projectId: string) =>
request.post<{ code: number; data: TagSuggestion }>(`/api/projects/${projectId}/suggest-tag`, undefined, { timeout: 60000 });
// ── 项目映射 ──
export const listMapping = () => request.get(`/api/roi/mapping`);
export const createMapping = (payload: any) => request.post(`/api/roi/mapping`, payload);
export const deleteMapping = (id: string) => request.delete(`/api/roi/mapping/${id}`);
export const listUnmapped = () => request.get(`/api/roi/unmapped`);
// ── 驱动因子 ──
export const fetchDriverFactors = (projectId: string, periodKey?: string) =>
request.get(`/api/projects/${projectId}/driver-factors`, { params: { periodKey } });

View File

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

View File

@ -18,62 +18,18 @@ const dashStore = useDashboardStore();
// B-17: Track whether the Projects sub-menu is expanded // B-17: Track whether the Projects sub-menu is expanded
const projectsExpanded = ref(false); const projectsExpanded = ref(false);
const projectList = ref<Array<{ projectId: string; name: string; identifier: string; bizSystem: string | null }>>([]); const projectList = ref<Array<{ projectId: string; name: string; identifier: string }>>([]);
async function loadProjectList() { async function loadProjectList() {
try { try {
const res = await getAdminProjectsApi(); const res = await getAdminProjectsApi();
const list = res.data.data || []; const list = res.data.data || [];
projectList.value = list.map((p: any) => ({ projectList.value = list.map((p: any) => ({ projectId: p.id, name: p.name, identifier: p.identifier || '' }));
projectId: p.id,
name: p.name,
identifier: p.identifier || '',
bizSystem: p.bizSystem || null,
}));
} catch { } catch {
// Silently fail // Silently fail
} }
} }
// 线
const BIZ_GROUPS: Array<{ key: 'airhubs' | 'airflow' | 'aircore' | 'uncategorized'; label: string; color: string }> = [
{ key: 'airhubs', label: 'airhubs · 硬件与潮玩业务线', color: '#0D9668' },
{ key: 'airflow', label: 'airflow · 内容生成与效能线', color: '#3B5998' },
{ key: 'aircore', label: 'aircore · 底层技术基座', color: '#D4920A' },
{ key: 'uncategorized', label: '未分类', color: '#6B7280' },
];
// (,localStorage )
const groupOpenState = ref<Record<string, boolean>>(
(() => {
try { return JSON.parse(localStorage.getItem('sidebar-group-open') || '{}'); }
catch { return {}; }
})()
);
function isGroupOpen(key: string): boolean {
return groupOpenState.value[key] !== false; // true(undefined )
}
function toggleGroup(key: string) {
groupOpenState.value = { ...groupOpenState.value, [key]: !isGroupOpen(key) };
try { localStorage.setItem('sidebar-group-open', JSON.stringify(groupOpenState.value)); } catch {}
}
const projectGroups = computed(() => {
const map: Record<string, typeof projectList.value> = {
airhubs: [], airflow: [], aircore: [], uncategorized: [],
};
for (const p of projectList.value) {
const k = p.bizSystem || 'uncategorized';
if (!map[k]) map[k] = [];
map[k].push(p);
}
return BIZ_GROUPS
.map(g => ({ ...g, projects: map[g.key] }))
.filter(g => g.projects.length > 0);
});
onMounted(loadProjectList); onMounted(loadProjectList);
// //
@ -102,11 +58,6 @@ const menuOptions = computed(() => {
items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' }); items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' });
} }
// ROI : admin
if (role === 'admin') {
items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' });
}
// B-17: Members nav item (admin/manager only) // B-17: Members nav item (admin/manager only)
if (role === 'admin' || role === 'manager') { if (role === 'admin' || role === 'manager') {
items.push({ label: '成员', key: '/members', icon: 'users' }); items.push({ label: '成员', key: '/members', icon: 'users' });
@ -127,7 +78,6 @@ const activeKey = computed(() => {
if (route.path.startsWith('/members/')) return '/members'; if (route.path.startsWith('/members/')) return '/members';
if (route.path.startsWith('/okr')) return '/okr'; if (route.path.startsWith('/okr')) return '/okr';
if (route.path.startsWith('/git')) return '/git'; if (route.path.startsWith('/git')) return '/git';
if (route.path.startsWith('/roi')) return '/roi';
if (route.path.startsWith('/admin')) return '/admin'; if (route.path.startsWith('/admin')) return '/admin';
return '/'; return '/';
}); });
@ -215,35 +165,20 @@ const roleTagType = computed(() => {
</NTooltip> </NTooltip>
</div> </div>
<!-- B-17: Projects sub-menu, grouped by bizSystem --> <!-- B-17: Projects sub-menu -->
<div <div
v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)" v-if="item.hasSubmenu && projectsExpanded && (!dashStore.sidebarCollapsed || dashStore.isMobile)"
class="submenu" class="submenu"
> >
<template v-for="group in projectGroups" :key="group.key"> <div
<div v-for="proj in projectList"
class="submenu-group-title" :key="proj.projectId"
:class="{ 'group-collapsed': !isGroupOpen(group.key) }" class="submenu-item"
:style="{ borderLeftColor: group.color }" :class="{ active: route.path === `/projects/${proj.projectId}` }"
@click="toggleGroup(group.key)" @click="handleProjectSelect(proj.projectId)"
> >
<span class="group-dot" :style="{ background: group.color }"></span> <span class="submenu-label">{{ proj.name }}</span>
<span class="group-arrow">{{ isGroupOpen(group.key) ? '▾' : '▸' }}</span> </div>
{{ group.label }}
<span class="group-count">{{ group.projects.length }}</span>
</div>
<template v-if="isGroupOpen(group.key)">
<div
v-for="proj in group.projects"
:key="proj.projectId"
class="submenu-item"
:class="{ active: route.path === `/projects/${proj.projectId}` }"
@click="handleProjectSelect(proj.projectId)"
>
<span class="submenu-label">{{ proj.name }}</span>
</div>
</template>
</template>
<div v-if="!projectList.length" class="submenu-item submenu-empty"> <div v-if="!projectList.length" class="submenu-item submenu-empty">
暂无项目 暂无项目
</div> </div>
@ -276,18 +211,17 @@ 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: var(--color-text-onDark); color: #E5E7EB;
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-out), transition: width var(--duration-collapse) var(--ease-default),
transform var(--duration-medium) var(--ease-out); transform 0.3s ease;
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 {
@ -335,24 +269,20 @@ const roleTagType = computed(() => {
.logo-icon { .logo-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-hover) 100%); background: var(--color-primary-hex);
border-radius: var(--radius-sm); border-radius: var(--radius-btn);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: var(--font-display); font-weight: 800;
font-weight: var(--weight-semibold); font-size: 12px;
font-size: 13px; color: white;
color: var(--color-text-onDark);
flex-shrink: 0; flex-shrink: 0;
letter-spacing: -0.02em;
} }
.logo-text { .logo-text {
font-family: var(--font-display); font-weight: 700;
font-weight: var(--weight-semibold); font-size: 16px;
font-size: var(--text-md);
letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
} }
@ -363,31 +293,25 @@ const roleTagType = computed(() => {
} }
.nav-item { .nav-item {
padding: var(--space-2) var(--space-4); padding: var(--space-3) var(--space-4);
margin-bottom: 2px; margin-bottom: var(--space-1);
border-radius: var(--radius-md); border-radius: var(--radius-btn);
cursor: pointer; cursor: pointer;
transition: background var(--duration-fast) var(--ease-out), transition: background var(--duration-hover) var(--ease-default);
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: oklch(0.24 0.014 230); background: rgba(255,255,255,0.08);
color: var(--color-text-onDark);
} }
.nav-item.active { .nav-item.active {
background: oklch(0.26 0.018 220); background: rgba(59,89,152,0.3);
color: var(--color-accent); border-left: 3px solid var(--color-primary-hex);
border-left: 2px solid var(--color-accent);
} }
.nav-label { .nav-label {
@ -444,51 +368,6 @@ const roleTagType = computed(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* 产品线分组标题 */
.submenu-group-title {
padding: 8px 12px 4px;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
letter-spacing: 0.3px;
border-left: 2px solid transparent;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
transition: color 0.15s, background 0.15s;
border-radius: 4px;
}
.submenu-group-title:hover {
color: #E5E7EB;
background: rgba(255,255,255,0.04);
}
.submenu-group-title.group-collapsed { opacity: 0.7; }
.group-arrow {
font-size: 10px;
width: 10px;
display: inline-flex;
justify-content: center;
color: #6B7280;
}
.group-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.group-count {
margin-left: auto;
font-size: 10px;
color: #6B7280;
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 8px;
font-weight: normal;
}
.nav-icon-only { .nav-icon-only {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,48 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const props = defineProps<{
byCategory: Record<string, { totalCost: number; totalRevenue: number; netProfit: number; projectCount: number }>;
}>();
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
uncategorized: '未打标',
};
const option = computed(() => {
const keys = Object.keys(props.byCategory);
const labels = keys.map(k => CATEGORY_LABELS[k] || k);
const costs = keys.map(k => props.byCategory[k].totalCost);
const revenues = keys.map(k => props.byCategory[k].totalRevenue);
const nets = keys.map(k => props.byCategory[k].netProfit);
return {
color: [CHART_COLORS[5], CHART_COLORS[1], CHART_COLORS[0]],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['成本', '产出', '净利'], top: 0 },
grid: { left: 60, right: 20, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
series: [
{ name: '成本', type: 'bar', stack: 'cost', data: costs },
{ name: '产出', type: 'bar', stack: 'revenue', data: revenues },
{ name: '净利', type: 'bar', stack: 'net', data: nets, itemStyle: { opacity: 0.6 } },
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="category-stacked-bar"></div>
</template>
<style scoped>
.category-stacked-bar { width: 100%; height: 320px; }
</style>

View File

@ -1,29 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { NTag } from 'naive-ui';
type Confidence = 'high' | 'medium' | 'low';
const props = defineProps<{
confidence: Confidence;
showLabel?: boolean;
}>();
const tagType = computed<'success' | 'warning' | 'error'>(() => {
if (props.confidence === 'high') return 'success';
if (props.confidence === 'medium') return 'warning';
return 'error';
});
const label = computed(() => {
if (props.confidence === 'high') return 'High';
if (props.confidence === 'medium') return 'Medium';
return 'Low';
});
</script>
<template>
<NTag :type="tagType" size="small" round>
<span v-if="showLabel !== false">置信度</span> {{ label }}
</NTag>
</template>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
NModal, NForm, NFormItem, NSelect, NDatePicker, NInputNumber, NInput, NButton, useMessage,
} from 'naive-ui';
import { createCostEvent, createRevenueEvent } from '@/api/roi';
const props = defineProps<{
show: boolean;
type: 'cost' | 'revenue';
projectId: string;
}>();
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>();
const message = useMessage();
const saving = ref(false);
const dateTs = ref<number | null>(Date.now());
const form = ref({
costType: 'hardware_bom' as 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other',
revenueType: 'direct_revenue' as 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other',
amount: 0,
channel: '',
notes: '',
});
const COST_OPTIONS = [
{ label: '研发工时', value: 'dev_hours' },
{ label: '硬件 BOM', value: 'hardware_bom' },
{ label: '服务费/运维', value: 'service_fee' },
{ label: '摊销', value: 'amortization' },
{ label: '其他', value: 'other' },
];
const REVENUE_OPTIONS = [
{ label: '直接营收', value: 'direct_revenue' },
{ label: '订阅', value: 'subscription' },
{ label: '节约成本(效能工具)', value: 'saved_cost' },
{ label: '资产增值(护城河)', value: 'asset_value_add' },
{ label: '退款/冲账', value: 'refund' },
{ label: '其他', value: 'other' },
];
watch(() => props.show, (s) => {
if (s) {
dateTs.value = Date.now();
form.value = { costType: 'hardware_bom', revenueType: 'direct_revenue', amount: 0, channel: '', notes: '' };
}
});
function formatDate(ts: number | null): string {
if (ts === null) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function handleSave() {
if (dateTs.value === null) { message.warning('请选择日期'); return; }
if (props.type === 'cost' && form.value.amount < 0) { message.warning('成本金额必须 >= 0'); return; }
saving.value = true;
try {
const eventDate = formatDate(dateTs.value);
if (props.type === 'cost') {
await createCostEvent(props.projectId, {
eventDate,
costType: form.value.costType,
amount: form.value.amount,
notes: form.value.notes || undefined,
});
} else {
await createRevenueEvent(props.projectId, {
eventDate,
revenueType: form.value.revenueType,
amount: form.value.amount,
channel: form.value.channel || undefined,
notes: form.value.notes || undefined,
});
}
message.success('已保存');
emit('saved');
emit('update:show', false);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<NModal :show="show" preset="card" :title="type === 'cost' ? '录入成本事件' : '录入产出事件'" style="width:500px"
@update:show="(v: boolean) => emit('update:show', v)">
<NForm label-placement="top" size="medium">
<NFormItem label="发生日期">
<NDatePicker v-model:value="dateTs" type="date" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'cost'" label="成本类型">
<NSelect v-model:value="form.costType" :options="COST_OPTIONS" />
</NFormItem>
<NFormItem v-else label="产出类型">
<NSelect v-model:value="form.revenueType" :options="REVENUE_OPTIONS" />
</NFormItem>
<NFormItem :label="type === 'cost' ? '金额(元)' : '金额(元,退款用负数)'">
<NInputNumber v-model:value="form.amount" :min="type === 'cost' ? 0 : -1e8" :max="1e8" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'revenue'" label="渠道(可选)">
<NInput v-model:value="form.channel" placeholder="alipay / wechat / stripe ..." />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" maxlength="500" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="emit('update:show', false)">取消</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
</div>
</NForm>
</NModal>
</template>

View File

@ -1,254 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
NTag, NButton, NModal, NForm, NFormItem, NSelect, NCheckboxGroup, NCheckbox,
NDatePicker, NInputNumber, NAlert, NSpin, useMessage,
} from 'naive-ui';
import {
tagProject, suggestTag,
type RoiCategory, type BizSystem, type ProjectType, type TagPayload,
} from '@/api/roi';
const props = defineProps<{
projectId: string;
initialCategory?: RoiCategory | null;
initialCompositeStrategies?: string[] | null;
initialBizSystem?: BizSystem | null;
initialProjectType?: ProjectType | null;
initialLaunchedAt?: string | null;
initialVAsset?: number | null;
canEdit: boolean;
}>();
const emit = defineEmits<{ saved: [] }>();
const message = useMessage();
const open = ref(false);
const saving = ref(false);
const suggesting = ref(false);
interface AiSuggestion {
category: RoiCategory;
bizSystem: BizSystem;
projectType: ProjectType;
confidence: number;
reasoning: string;
}
const suggestion = ref<AiSuggestion | null>(null);
const form = ref<TagPayload>({
category: 'cash_cow',
compositeStrategies: null,
bizSystem: null,
projectType: null,
launchedAt: null,
vAsset: null,
});
const CATEGORY_META: Record<RoiCategory, { label: string; emoji: string; color: 'success' | 'info' | 'warning' | 'error' }> = {
cash_cow: { label: '现金牛', emoji: '💰', color: 'success' },
efficiency_tool: { label: '效能工具', emoji: '⚙️', color: 'info' },
moat: { label: '资本护城河', emoji: '💎', color: 'warning' },
composite: { label: '复合型', emoji: '🚀', color: 'error' },
};
const BIZ_META: Record<BizSystem, { label: string; sub: string }> = {
airhubs: { label: 'airhubs', sub: '硬件与潮玩业务线' },
airflow: { label: 'airflow', sub: '内容生成与效能线' },
aircore: { label: 'aircore', sub: '底层技术基座' },
};
const TYPE_LABEL: Record<ProjectType, string> = {
hardware: '硬件',
software: '软件',
};
const categoryOptions = (Object.keys(CATEGORY_META) as RoiCategory[]).map(k => ({
label: `${CATEGORY_META[k].emoji} ${CATEGORY_META[k].label}`,
value: k,
}));
const bizSystemOptions = (Object.keys(BIZ_META) as BizSystem[]).map(k => ({
label: `${BIZ_META[k].label}${BIZ_META[k].sub}`,
value: k,
}));
const projectTypeOptions = [
{ label: '🔧 硬件 (hardware)', value: 'hardware' },
{ label: '💻 软件 (software)', value: 'software' },
];
const subStrategyOptions = [
{ label: '💰 现金牛', value: 'cash_cow' },
{ label: '⚙️ 效能工具', value: 'efficiency_tool' },
{ label: '💎 资本护城河', value: 'moat' },
];
const needsAsset = computed(() => {
if (form.value.category === 'moat') return true;
if (form.value.category === 'composite' && (form.value.compositeStrategies || []).includes('moat')) return true;
return false;
});
const willRegenerateIdentifier = computed(() =>
!!(form.value.bizSystem && form.value.projectType) &&
(form.value.bizSystem !== props.initialBizSystem || form.value.projectType !== props.initialProjectType)
);
const launchedAtTs = ref<number | null>(null);
function openModal() {
form.value = {
category: props.initialCategory || 'cash_cow',
compositeStrategies: props.initialCompositeStrategies as any || null,
bizSystem: props.initialBizSystem || null,
projectType: props.initialProjectType || null,
launchedAt: props.initialLaunchedAt || null,
vAsset: props.initialVAsset || null,
};
launchedAtTs.value = props.initialLaunchedAt ? new Date(props.initialLaunchedAt).getTime() : null;
suggestion.value = null;
open.value = true;
}
watch(launchedAtTs, (ts) => {
if (ts === null) {
form.value.launchedAt = null;
} else {
const d = new Date(ts);
form.value.launchedAt = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
});
async function handleSuggest() {
suggesting.value = true;
try {
const res = await suggestTag(props.projectId);
const s = res.data.data;
suggestion.value = {
category: s.suggestedCategory,
bizSystem: s.suggestedBizSystem,
projectType: s.suggestedProjectType,
confidence: s.confidence,
reasoning: s.reasoning,
};
} catch (e: any) {
message.error('AI 推荐失败:' + (e?.response?.data?.message || e.message));
} finally {
suggesting.value = false;
}
}
function adoptSuggestion() {
if (!suggestion.value) return;
form.value.category = suggestion.value.category;
form.value.bizSystem = suggestion.value.bizSystem;
form.value.projectType = suggestion.value.projectType;
message.success('已采纳 AI 建议');
}
async function handleSave() {
if (needsAsset.value && (!form.value.vAsset || form.value.vAsset <= 0)) {
message.warning('资本护城河项目必须填写技术资产估值');
return;
}
saving.value = true;
try {
const res = await tagProject(props.projectId, form.value);
const newId = res.data?.data?.identifier;
if (newId && newId !== props.initialCategory) {
message.success(`已保存,新标识:${newId}`);
} else {
message.success('已保存');
}
open.value = false;
emit('saved');
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="project-tag-selector">
<NTag v-if="initialCategory" :type="CATEGORY_META[initialCategory].color" round size="medium">
{{ CATEGORY_META[initialCategory].emoji }} {{ CATEGORY_META[initialCategory].label }}
</NTag>
<NTag v-else type="default" round size="medium">未打标</NTag>
<NButton v-if="canEdit" size="tiny" text type="primary" @click="openModal" style="margin-left:8px">
{{ initialCategory ? '编辑' : '+ 打标' }}
</NButton>
<NModal v-model:show="open" preset="card" title="项目商业定位打标" style="width:640px">
<NSpin :show="saving || suggesting">
<NForm label-placement="top" size="medium">
<NFormItem label="业务体系归属(决定项目标识前缀)">
<NSelect v-model:value="form.bizSystem" :options="bizSystemOptions" placeholder="选择 airhubs / airflow / aircore" clearable />
</NFormItem>
<NFormItem label="技术属性(硬件 / 软件)">
<NSelect v-model:value="form.projectType" :options="projectTypeOptions" placeholder="选择 hardware / software" clearable />
</NFormItem>
<NAlert v-if="willRegenerateIdentifier" type="info" style="margin:-4px 0 12px" :show-icon="false">
保存后将自动生成新标识(格式 {{ form.bizSystem }}-{{ form.projectType === 'hardware' ? 'hw' : 'sw' }}-XXX)
</NAlert>
<NFormItem label="ROI 分类标签">
<NSelect v-model:value="form.category" :options="categoryOptions" />
</NFormItem>
<NFormItem v-if="form.category === 'composite'" label="复合策略组合(至少选 2 个)">
<NCheckboxGroup v-model:value="form.compositeStrategies">
<div style="display:flex;gap:12px">
<NCheckbox v-for="opt in subStrategyOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</div>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="立项日期(累计 ROI 起算点)">
<NDatePicker v-model:value="launchedAtTs" type="date" clearable style="width:100%" />
</NFormItem>
<NFormItem v-if="needsAsset" label="技术资产估值(元) — 资本护城河必填">
<NInputNumber v-model:value="form.vAsset" :min="0" :max="1e10" placeholder="如 360000" style="width:100%" />
</NFormItem>
<NAlert v-if="suggestion" type="info" style="margin:12px 0">
<div style="display:flex;justify-content:space-between;align-items:start;gap:12px">
<div style="flex:1">
<div style="font-weight:600;line-height:1.6">
AI 建议:
<NTag size="small" :type="CATEGORY_META[suggestion.category].color" round>{{ CATEGORY_META[suggestion.category].emoji }} {{ CATEGORY_META[suggestion.category].label }}</NTag>
<NTag size="small" type="info" round>{{ BIZ_META[suggestion.bizSystem].label }}</NTag>
<NTag size="small" type="default" round>{{ TYPE_LABEL[suggestion.projectType] }}</NTag>
<span style="color:var(--color-text-muted);margin-left:8px;font-weight:normal">置信度 {{ Math.round(suggestion.confidence * 100) }}%</span>
</div>
<div style="margin-top:6px;color:var(--color-text-muted);font-size:13px;line-height:1.5">{{ suggestion.reasoning }}</div>
</div>
<NButton size="small" type="primary" @click="adoptSuggestion">采纳</NButton>
</div>
</NAlert>
<div style="display:flex;justify-content:space-between;margin-top:16px">
<NButton @click="handleSuggest" :loading="suggesting">🤖 AI 推荐</NButton>
<div style="display:flex;gap:8px">
<NButton @click="open = false">取消</NButton>
<NButton type="primary" @click="handleSave" :loading="saving">保存</NButton>
</div>
</div>
</NForm>
</NSpin>
</NModal>
</div>
</template>
<style scoped>
.project-tag-selector {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View File

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

View File

@ -1,56 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import type { TimeseriesBucket } from '@/api/roi';
const props = defineProps<{
buckets: TimeseriesBucket[];
granularity: 'day' | 'week' | 'month' | 'year';
}>();
const option = computed(() => {
const labels = props.buckets.map(b => b.bucket);
const costs = props.buckets.map(b => b.cost);
const revenues = props.buckets.map(b => b.revenue);
const cumRoi = props.buckets.map(b => b.cumulativeRoi);
return {
color: CHART_COLORS,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['本期成本', '本期产出', '累计 ROI'], top: 0 },
grid: { left: 60, right: 70, top: 35, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '¥', position: 'left', axisLabel: { formatter: (v: number) => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v.toFixed(0) } },
{ type: 'value', name: 'ROI %', position: 'right', axisLabel: { formatter: '{value}%' } },
],
series: [
{ name: '本期成本', type: 'bar', yAxisIndex: 0, data: costs, itemStyle: { color: CHART_COLORS[5] } },
{ name: '本期产出', type: 'bar', yAxisIndex: 0, data: revenues, itemStyle: { color: CHART_COLORS[1] } },
{
name: '累计 ROI',
type: 'line',
yAxisIndex: 1,
data: cumRoi,
smooth: true,
lineStyle: { width: 2 },
itemStyle: { color: CHART_COLORS[0] },
markLine: { silent: true, lineStyle: { color: '#888' }, data: [{ yAxis: 0, label: { formatter: '回本线' } }] },
},
],
};
});
const { chartRef } = useECharts(option);
</script>
<template>
<div ref="chartRef" class="roi-timeseries-chart"></div>
</template>
<style scoped>
.roi-timeseries-chart {
width: 100%;
height: 320px;
}
</style>

View File

@ -1,123 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import {
NSpin, NButton, NDataTable, NModal, NForm, NFormItem, NInput, NSelect, NSwitch, NTag, useMessage,
} from 'naive-ui';
import { listMapping, createMapping, deleteMapping, listUnmapped } from '@/api/roi';
import request from '@/api/request';
const message = useMessage();
const loading = ref(true);
const mappings = ref<any[]>([]);
const unmapped = ref<any[]>([]);
const projectOptions = ref<{ label: string; value: string }[]>([]);
const showModal = ref(false);
const form = ref({ projectId: '', businessProjectKey: '', enabled: true, notes: '' });
async function load() {
loading.value = true;
try {
const [m, u, p] = await Promise.all([
listMapping(),
listUnmapped(),
request.get('/api/projects'),
]);
mappings.value = m.data.data || [];
unmapped.value = u.data.data || [];
projectOptions.value = (p.data.data || []).map((x: any) => ({
label: `${x.identifier || x.id} - ${x.name}`,
value: x.id,
}));
} finally { loading.value = false; }
}
onMounted(load);
async function handleCreate() {
if (!form.value.projectId || !form.value.businessProjectKey) {
message.warning('请填写所有必填项');
return;
}
try {
await createMapping(form.value);
message.success('已新增映射');
showModal.value = false;
form.value = { projectId: '', businessProjectKey: '', enabled: true, notes: '' };
await load();
} catch (e: any) {
message.error('新增失败:' + (e?.response?.data?.message || e.message));
}
}
async function handleDelete(id: string) {
if (!confirm('确认删除该映射?')) return;
await deleteMapping(id);
message.success('已删除');
await load();
}
const mappingColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: 'DevPerf 项目', key: 'projectId' },
{ title: '启用', key: 'enabled', render: (row: any) => row.enabled ? '✅' : '⛔' },
{ title: '备注', key: 'notes' },
{ title: '操作', key: 'actions', render: (row: any) => h(NButton, {
size: 'tiny', type: 'error', onClick: () => handleDelete(row.id),
}, () => '删除') },
];
const unmappedColumns = [
{ title: '业务方 Key', key: 'businessProjectKey' },
{ title: '日期', key: 'eventDate', render: (row: any) => row.eventDate?.slice(0, 10) },
{ title: '金额', key: 'amount', render: (row: any) => `¥${Number(row.amount).toLocaleString()}` },
{ title: '类型', key: 'revenueType' },
{ title: '状态', key: 'status' },
];
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
把外部业务系统的"项目 key"映射到 DevPerf 项目新增映射后,未来抓到的营收数据自动归到对应项目;之前堆在"未映射"里的数据需手动处理
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong>当前映射 ({{ mappings.length }})</strong>
<NButton type="primary" size="small" @click="showModal = true">+ 添加映射</NButton>
</div>
<div class="table-shell">
<NDataTable :columns="mappingColumns" :data="mappings" size="small" :bordered="false" />
</div>
<div style="margin-top:24px">
<strong style="color:var(--color-text-muted)"> 未映射的营收事件 ({{ unmapped.length }})</strong>
<div style="font-size:12px;color:var(--color-text-muted);margin:6px 0">
外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理新增对应映射后,后续数据会自动归类
</div>
<div class="table-shell">
<NDataTable :columns="unmappedColumns" :data="unmapped" size="small" :bordered="false" :max-height="300" />
</div>
</div>
<NModal v-model:show="showModal" preset="card" title="新增项目映射" style="width:500px">
<NForm label-placement="top">
<NFormItem label="业务方项目 Key(外部系统的 key)">
<NInput v-model:value="form.businessProjectKey" placeholder="如 PROD-A001" />
</NFormItem>
<NFormItem label="对应 DevPerf 项目">
<NSelect v-model:value="form.projectId" :options="projectOptions" filterable />
</NFormItem>
<NFormItem label="启用">
<NSwitch v-model:value="form.enabled" />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 3 }" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="showModal = false">取消</NButton>
<NButton type="primary" @click="handleCreate">新增</NButton>
</div>
</NForm>
</NModal>
</NSpin>
</template>

View File

@ -1,73 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
NSpin, NCard, NForm, NFormItem, NInputNumber, NButton, useMessage,
} from 'naive-ui';
import { fetchStrategies, updateStrategy, type Strategy } from '@/api/roi';
const message = useMessage();
const loading = ref(true);
const strategies = ref<Strategy[]>([]);
const saving = ref<Record<string, boolean>>({});
async function load() {
loading.value = true;
try {
const res = await fetchStrategies();
strategies.value = res.data.data;
} finally { loading.value = false; }
}
onMounted(load);
async function save(s: Strategy) {
saving.value[s.id] = true;
try {
await updateStrategy(s.id, s.params);
message.success('已保存:' + s.name);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value[s.id] = false;
}
}
const CATEGORY_LABELS: Record<string, string> = {
cash_cow: '💰 现金牛',
efficiency_tool: '⚙️ 效能工具',
moat: '💎 资本护城河',
composite: '🚀 复合型',
};
</script>
<template>
<NSpin :show="loading">
<div style="margin-bottom:12px;color:var(--color-text-muted);font-size:13px">
调整全局 ROI 计算参数修改后只影响新增的成本事件,历史数据用当时的 R_h 快照,不受影响
</div>
<div class="strategies-grid">
<NCard v-for="s in strategies" :key="s.id" size="small" :title="CATEGORY_LABELS[s.category] || s.category">
<NForm label-placement="left" label-width="160" size="small">
<NFormItem label="综合人时成本(¥/h)">
<NInputNumber v-model:value="s.params.hourlyRate" :min="0" :max="10000" style="width:120px" />
</NFormItem>
<NFormItem v-if="s.category === 'moat' || s.category === 'composite'" label="资产摊销年限(年)">
<NInputNumber v-model:value="s.params.amortYears" :min="1" :max="20" style="width:120px" />
</NFormItem>
<NFormItem label="每 commit 系数(h)">
<NInputNumber v-model:value="s.params.commitHourCoef" :min="0" :max="40" :step="0.1" style="width:120px" />
</NFormItem>
<NFormItem label="每 task 系数(h)">
<NInputNumber v-model:value="s.params.taskHourCoef" :min="0" :max="80" :step="0.5" style="width:120px" />
</NFormItem>
<NButton type="primary" size="small" :loading="saving[s.id]" @click="save(s)">保存</NButton>
</NForm>
</NCard>
</div>
</NSpin>
</template>
<style scoped>
.strategies-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 900px) { .strategies-grid { grid-template-columns: 1fr; } }
</style>

View File

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

View File

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

View File

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

View File

@ -1,181 +1,77 @@
/* ============================================================
* 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 {
/* ─────────── 色彩系统 (OKLCH) ─────────── */ /* Primary - Trusted Indigo */
/* 主色 — 深墨青(克制信任, 不是 Tailwind blue) */ --color-primary: oklch(0.45 0.12 255);
--color-primary: oklch(0.32 0.05 200); --color-primary-hex: #3B5998;
--color-primary-hover: oklch(0.27 0.05 200); --color-primary-hover: oklch(0.40 0.12 255);
--color-primary-press: oklch(0.23 0.05 200); --color-primary-light: oklch(0.92 0.03 255);
--color-primary-soft: oklch(0.94 0.018 200);
/* 强调色 — 暖琥珀(数据高亮 / CTA) */ /* Accent - Amber */
--color-accent: oklch(0.72 0.16 65); --color-accent: oklch(0.75 0.15 75);
--color-accent-hover: oklch(0.66 0.17 60); --color-accent-hex: #D4920A;
--color-accent-soft: oklch(0.96 0.04 75);
/* 语义色 */ /* Semantic */
--color-success: oklch(0.58 0.13 155); --color-success: #0D9668;
--color-success-soft: oklch(0.95 0.04 155); --color-warning: #D4920A;
--color-warning: oklch(0.72 0.16 65); --color-error: #DC2626;
--color-warning-soft: oklch(0.96 0.04 75); --color-info: #2B8CA3;
--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);
/* 中性色阶(暖灰, chroma 0.005~0.012, 不是死灰) */ /* Chart palette */
--color-bg: oklch(0.985 0.003 80); /* 页面背景 */ --chart-1: #3B5998;
--color-bg-card: oklch(1.000 0.000 0); /* 卡片 */ --chart-2: #0D9668;
--color-bg-subtle: oklch(0.965 0.004 80); /* 浅灰区块 */ --chart-3: #D4920A;
--color-bg-hover: oklch(0.955 0.005 80); /* hover */ --chart-4: #7C4DBA;
--color-bg-sidebar: oklch(0.18 0.012 230); /* 侧边栏深底带蓝绿 */ --chart-5: #2B8CA3;
--color-bg-sidebar-2: oklch(0.21 0.013 230); /* 侧边栏次级 */
--color-border-subtle: oklch(0.935 0.006 80); /* Neutral */
--color-border: oklch(0.88 0.008 80); --color-bg: #F8F9FB;
--color-border-strong: oklch(0.78 0.010 80); --color-bg-card: #FFFFFF;
--color-bg-sidebar: #1E2433;
--color-text-primary: #1A1F2E;
--color-text-secondary: #6B7280;
--color-text-muted: #9CA3AF;
--color-border: #E5E7EB;
--color-text-muted: oklch(0.58 0.009 80); /* Typography */
--color-text-secondary: oklch(0.42 0.011 80); --font-heading: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--color-text-primary: oklch(0.22 0.012 80); --font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--color-text-onDark: oklch(0.92 0.005 80); --font-code: 'JetBrains Mono', 'Fira Code', monospace;
--color-text-onDarkMuted:oklch(0.65 0.010 220);
/* 兼容老变量 (保持现有组件不破) */ /* Spacing */
--color-primary-hex: #1f3a45; --space-1: 4px;
--color-accent-hex: #c47918; --space-2: 8px;
--color-text-secondary-legacy: var(--color-text-secondary); --space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ─────────── 图表色板(OKLCH 衍生, 8 色, 中等饱和) ─────────── */ /* Border radius */
--chart-1: oklch(0.32 0.05 200); /* 墨青 */ --radius-btn: 8px;
--chart-2: oklch(0.58 0.13 155); /* 翠绿 */ --radius-card: 12px;
--chart-3: oklch(0.72 0.16 65); /* 琥珀 */ --radius-modal: 16px;
--chart-4: oklch(0.50 0.15 280); /* 紫罗兰 */ --radius-pill: 9999px;
--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 */
/* Display: Newsreader 衬线(editorial 标题) */ --ease-default: cubic-bezier(0.25, 1, 0.5, 1);
--font-display: 'Newsreader', 'Source Serif 4', 'Songti SC', 'STSong', Georgia, serif; --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);
/* Sans: Geist(Vercel 现代无衬线, 替代 Inter) + PingFang */ --duration-hover: 200ms;
--font-sans: 'Geist', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; --duration-entrance: 600ms;
/* Mono: JetBrains Mono(等宽 KPI 数字) */ --duration-collapse: 300ms;
--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;
/* ─────────── Layout ─────────── */ /* Sidebar */
--sidebar-width: 240px; --sidebar-width: 240px;
--sidebar-collapsed-width: 64px; --sidebar-collapsed-width: 64px;
--content-max: 1440px;
} }
/* ─────────── Reset ─────────── */
*, *,
*::before, *::before,
*::after { *::after {
@ -191,158 +87,58 @@ html, body {
} }
html { html {
font-family: var(--font-sans); font-family: var(--font-body);
font-size: var(--text-base); font-size: 14px;
line-height: var(--leading-normal); line-height: 1.6;
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;
} }
/* 标题: 中文用 sans(PingFang)更协调, 衬线只用在英文 eyebrow */ h1, h2, h3, h4, h5, h6 {
h1, h2, h3 { font-family: var(--font-heading);
font-family: var(--font-sans); font-weight: 700;
font-weight: var(--weight-semibold); line-height: 1.3;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
} }
h4, h5, h6 { code, pre {
font-family: var(--font-sans); font-family: var(--font-code);
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); color: var(--color-primary-hex);
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;
} }
/* focus-visible(键盘用户) */ a:hover {
:focus-visible { text-decoration: underline;
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
} }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
height: 8px; height: 6px;
} }
::-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: var(--radius-full); border-radius: 3px;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-border-strong); background: var(--color-text-muted);
background-clip: padding-box;
} }
/* 减少动效偏好 */ /* Tabular figures for numbers */
@media (prefers-reduced-motion: reduce) { .tabular-nums {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* utility */
.surface { background: var(--color-bg-card); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-xl); }
.hairline { border-bottom: 1px solid var(--color-border-subtle); }
/* 表格容器统一 wrapper:白底 + 边框 + 圆角,跟 list-card / DataCard 视觉一致 */
.table-shell {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
overflow: hidden;
}
/* ============================================================
* 全局表格基线 (Naive UI NDataTable 统一样式)
* Editorial Data Console 风格:浅灰表头 + dashed 行分隔 + hover 高亮
* ============================================================ */
.n-data-table .n-data-table-th {
background: var(--color-bg-subtle) !important;
color: var(--color-text-secondary) !important;
font-family: var(--font-sans) !important;
font-size: 12px !important;
font-weight: var(--weight-medium) !important;
letter-spacing: 0.02em !important;
padding: 10px 16px !important;
border-bottom: 1px solid var(--color-border) !important;
border-right: none !important;
}
.n-data-table .n-data-table-td {
padding: 12px 16px !important;
border-bottom: 1px solid var(--color-border-subtle) !important;
border-right: none !important;
font-size: var(--text-sm);
color: var(--color-text-primary);
transition: background var(--duration-fast) var(--ease-out);
}
.n-data-table .n-data-table-tr:hover .n-data-table-td {
background: var(--color-bg-subtle) !important;
}
.n-data-table .n-data-table-tr:last-child .n-data-table-td {
border-bottom: none !important;
}
/* 数字单元格自动等宽 (用 .tabular-nums class 或 td 内 .tabular-nums span) */
.n-data-table .n-data-table-td .tabular-nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* 空状态贴齐主题 */
.n-data-table .n-data-table-empty {
padding: var(--space-10) var(--space-4) !important;
color: var(--color-text-muted);
}

View File

@ -1,68 +1,27 @@
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: {
// 主色板 (OKLCH 转近似 hex) primaryColor: '#3B5998',
primaryColor: '#1f3a45', primaryColorHover: '#2D4373',
primaryColorHover: '#173039', primaryColorPressed: '#1E2D4F',
primaryColorPressed: '#0f2730', primaryColorSuppl: '#3B5998',
primaryColorSuppl: '#1f3a45', infoColor: '#2B8CA3',
successColor: '#0D9668',
// 语义色 (OKLCH 转近似 hex) warningColor: '#D4920A',
infoColor: '#2d5d8c', errorColor: '#DC2626',
infoColorHover: '#264f78', fontFamily: "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif",
successColor: '#317a5d', fontFamilyMono: "'JetBrains Mono', 'Fira Code', monospace",
successColorHover: '#2a6b51', borderRadius: '8px',
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: '14px', borderRadius: '12px',
paddingMedium: '20px 24px',
color: '#ffffff',
borderColor: '#eaeded',
}, },
Dialog: { Dialog: {
borderRadius: '16px', borderRadius: '16px',
@ -72,28 +31,8 @@ export const naiveThemeOverrides: GlobalThemeOverrides = {
}, },
DataTable: { DataTable: {
borderRadius: '12px', borderRadius: '12px',
thColor: '#fafbfb',
thColorHover: '#f3f5f6',
thTextColor: '#4d5258',
thFontWeight: '600',
fontSize: '13px',
}, },
Tag: { Tag: {
borderRadius: '6px', borderRadius: '6px',
fontWeightStrong: '500',
},
Tabs: {
tabFontSize: '14px',
tabFontWeightActive: '600',
},
Tooltip: {
borderRadius: '8px',
fontSize: '13px',
},
Modal: {
color: '#ffffff',
},
Drawer: {
color: '#ffffff',
}, },
}; };

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'; import { ref, computed, onMounted, h } from 'vue';
import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui'; import { NTabs, NTabPane, NDataTable, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSpin, NTag, useMessage } from 'naive-ui';
import StrategiesPanel from '@/components/roi/admin/StrategiesPanel.vue';
import MappingPanel from '@/components/roi/admin/MappingPanel.vue';
import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin'; import { getAdminUsersApi, createUserApi, deleteUserApi, getAuthorMappingsApi, createMappingApi, deleteMappingApi, getSyncLogsApi, triggerSyncApi, getAdminProjectsApi, setUserProjectsApi } from '@/api/admin';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -316,14 +314,6 @@ const roleOptions = [
<NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" /> <NDataTable :columns="logColumns" :data="logsData" :loading="logsLoading" :bordered="false" size="small" />
</div> </div>
</NTabPane> </NTabPane>
<NTabPane name="roi-strategies" tab="ROI 策略">
<StrategiesPanel />
</NTabPane>
<NTabPane name="roi-mapping" tab="ROI 项目映射">
<MappingPanel />
</NTabPane>
</NTabs> </NTabs>
<!-- 创建用户弹窗 --> <!-- 创建用户弹窗 -->
@ -387,8 +377,5 @@ const roleOptions = [
.table-responsive { .table-responsive {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
} }
</style> </style>

View File

@ -6,7 +6,6 @@ import { getProjectDetailApi } from '@/api/projects';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr'; import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
import request from '@/api/request'; import request from '@/api/request';
import DataCard from '@/components/shared/DataCard.vue'; import DataCard from '@/components/shared/DataCard.vue';
import ProjectTagSelector from '@/components/roi/ProjectTagSelector.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts'; import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -35,10 +34,6 @@ onMounted(loadData);
// //
watch(() => route.params.id, () => loadData()); watch(() => route.params.id, () => loadData());
function goToRoiBoard() {
router.push(`/projects/${projectId.value}/roi`);
}
// //
const allUsers = ref<any[]>([]); const allUsers = ref<any[]>([]);
const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName }))); const userOptions = computed(() => allUsers.value.map(u => ({ value: u.id, label: u.displayName })));
@ -329,27 +324,8 @@ function canEditObj(obj: any): boolean {
<!-- 项目标题 + 整体进度 --> <!-- 项目标题 + 整体进度 -->
<div class="overall-progress"> <div class="overall-progress">
<div style="display:flex;justify-content:space-between;align-items:center"> <div style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;align-items:center;gap:12px"> <h2 style="margin:0;font-size:18px">{{ data.project?.identifier }} - {{ data.project?.name }}</h2>
<h2 style="margin:0;font-size:18px">{{ data.project?.name }}</h2> <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>
<ProjectTagSelector
v-if="authStore.isAdmin"
:project-id="projectId"
:initial-category="data.project?.category || null"
:initial-composite-strategies="data.project?.compositeStrategies || null"
:initial-biz-system="data.project?.bizSystem || null"
:initial-project-type="data.project?.projectType || null"
:initial-launched-at="data.project?.launchedAt || null"
:initial-v-asset="data.project?.vAsset || null"
:can-edit="true"
@saved="loadData"
/>
</div>
<div style="display:flex;align-items:center;gap:12px">
<NButton v-if="authStore.isAdmin" size="small" type="primary" ghost @click="goToRoiBoard">
💎 ROI 看板
</NButton>
<span class="tabular-nums" style="font-size:22px;font-weight:700;color:var(--color-primary-hex)" v-if="data.okr?.objectives?.length">{{ data.okr.overallProgress }}%</span>
</div>
</div> </div>
<div v-if="data.okr?.objectives?.length" style="margin-top:8px"> <div v-if="data.okr?.objectives?.length" style="margin-top:8px">
<NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" /> <NProgress type="line" :percentage="clamp(data.okr.overallProgress)" :show-indicator="false" style="width:100%" />

View File

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

View File

@ -1,505 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
NSpin, NButton, NSelect, NDatePicker, NTag, NEmpty, NCard, NTimeline, NTimelineItem, useMessage,
} from 'naive-ui';
import dayjs from 'dayjs';
import {
aggregateRoi, timeseriesRoi, listEvents, fetchDriverFactors,
type AggregateResult, type TimeseriesBucket, type DriverFactor,
} from '@/api/roi';
import request from '@/api/request';
import { useAuthStore } from '@/stores/auth';
import ConfidenceBadge from '@/components/roi/ConfidenceBadge.vue';
import RoiTimeSeriesChart from '@/components/roi/RoiTimeSeriesChart.vue';
import EventEntryModal from '@/components/roi/EventEntryModal.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const message = useMessage();
const projectId = computed(() => route.params.id as string);
const loading = ref(true);
const project = ref<any>(null);
//
const lifetimeAgg = ref<AggregateResult | null>(null);
const monthAgg = ref<AggregateResult | null>(null);
const ytdAgg = ref<AggregateResult | null>(null);
const tsBuckets = ref<TimeseriesBucket[]>([]);
const granularity = ref<'week' | 'month' | 'year'>('month');
const driverFactors = ref<DriverFactor[]>([]);
const recentCostEvents = ref<any[]>([]);
const recentRevenueEvents = ref<any[]>([]);
const entryModalType = ref<'cost' | 'revenue'>('revenue');
const showEntryModal = ref(false);
function todayStr(): string {
return dayjs().format('YYYY-MM-DD');
}
function monthStartStr(): string {
return dayjs().startOf('month').format('YYYY-MM-DD');
}
function yearStartStr(): string {
return dayjs().startOf('year').format('YYYY-MM-DD');
}
const launchedAtStr = computed(() => {
if (!project.value?.launchedAt) return null;
return dayjs(project.value.launchedAt).format('YYYY-MM-DD');
});
async function loadProject() {
const res = await request.get(`/api/projects/${projectId.value}`);
project.value = res.data.data.project;
}
async function loadAggregates() {
const today = todayStr();
// ROI launchedAt ; 1900-01-01 aggregator
const lifetimeFrom = launchedAtStr.value || '1900-01-01';
const [lifetime, month, ytd] = await Promise.all([
aggregateRoi(projectId.value, lifetimeFrom, today),
aggregateRoi(projectId.value, monthStartStr(), today),
aggregateRoi(projectId.value, yearStartStr(), today),
]);
lifetimeAgg.value = lifetime.data.data;
monthAgg.value = month.data.data;
ytdAgg.value = ytd.data.data;
}
async function loadTimeseries() {
const today = todayStr();
let from: string;
switch (granularity.value) {
case 'week': from = dayjs().subtract(26, 'week').startOf('week').format('YYYY-MM-DD'); break;
case 'month': from = dayjs().subtract(12, 'month').startOf('month').format('YYYY-MM-DD'); break;
case 'year': from = dayjs().subtract(5, 'year').startOf('year').format('YYYY-MM-DD'); break;
}
const res = await timeseriesRoi(projectId.value, from, today, granularity.value);
tsBuckets.value = res.data.data;
}
async function loadDriverFactors() {
try {
const res = await fetchDriverFactors(projectId.value);
const latest = (res.data.data || [])[0];
driverFactors.value = (latest?.factors as DriverFactor[]) || [];
} catch { driverFactors.value = []; }
}
async function loadRecentEvents() {
const [cost, revenue] = await Promise.all([
listEvents(projectId.value, 'cost', undefined, undefined, 30),
listEvents(projectId.value, 'revenue', undefined, undefined, 30),
]);
recentCostEvents.value = cost.data.data || [];
recentRevenueEvents.value = revenue.data.data || [];
}
async function loadAll() {
loading.value = true;
try {
await loadProject();
await Promise.all([loadAggregates(), loadTimeseries(), loadDriverFactors(), loadRecentEvents()]);
} catch (e: any) {
message.error('加载失败:' + (e?.response?.data?.message || e.message));
} finally {
loading.value = false;
}
}
onMounted(loadAll);
watch(() => projectId.value, loadAll);
watch(granularity, loadTimeseries);
function fmtCurrency(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `¥${Math.round(n).toLocaleString()}`;
}
function fmtPercent(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n.toFixed(1)}%`;
}
function roiColor(n: number | null | undefined): string {
if (n === null || n === undefined) return 'var(--color-text-muted)';
if (n >= 100) return 'var(--color-success)';
if (n >= 0) return 'var(--color-warning)';
return 'var(--color-error)';
}
function bepDisplay(): string {
const bep = lifetimeAgg.value?.bepDays;
if (bep === null || bep === undefined) return '按当前趋势暂无法回本';
if (bep === 0) return '已回本 ✓';
const months = Math.round(bep / 30 * 10) / 10;
return `预计 ${months} 月回本`;
}
const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null);
</script>
<template>
<div class="roi-board-page">
<NSpin :show="loading">
<!-- Editorial 页头 -->
<header class="board-header">
<div class="header-left">
<button class="back-link" @click="router.push(`/projects/${projectId}`)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回项目
</button>
<div class="eyebrow" v-if="project">ROI Board</div>
<h1 class="board-title" v-if="project">{{ project.name }}</h1>
<p class="board-lede" v-if="lifetimeAgg">立项以来 · 累计 ROI <span class="tabular-nums" :style="{ color: roiColor(lifetimeAgg.roiValue) }">{{ fmtPercent(lifetimeAgg.roiValue) }}</span> · {{ bepDisplay() }}</p>
</div>
<div class="header-actions" v-if="authStore.canEdit">
<button class="action-btn action-btn-secondary" @click="entryModalType = 'cost'; showEntryModal = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入成本
</button>
<button class="action-btn action-btn-primary" @click="entryModalType = 'revenue'; showEntryModal = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
录入产出
</button>
</div>
</header>
<!-- 顶部 4 张大卡片 -->
<div class="kpi-grid">
<article class="kpi-card">
<div class="kpi-label">历史总造价</div>
<div class="kpi-value tabular-nums">{{ fmtCurrency(lifetimeAgg?.totalCost) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg"> {{ launchedAtStr || '立项' }} 至今</div>
</article>
<article class="kpi-card">
<div class="kpi-label">历史总产出</div>
<div class="kpi-value tabular-nums" style="color: var(--color-success)">{{ fmtCurrency(lifetimeAgg?.totalRevenue) }}</div>
<div class="kpi-sub" v-if="lifetimeAgg">净利 <span class="tabular-nums">{{ fmtCurrency(lifetimeAgg?.netProfit) }}</span></div>
</article>
<article class="kpi-card kpi-card-highlight">
<div class="kpi-label">累计 ROI</div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(lifetimeAgg?.roiValue) }">
{{ fmtPercent(lifetimeAgg?.roiValue) }}
</div>
<div class="kpi-sub kpi-sub-confidence" :class="{ 'kpi-sub-warn': isBepWarn }">
<ConfidenceBadge v-if="lifetimeAgg" :confidence="lifetimeAgg.confidence" :show-label="false" />
<span>{{ bepDisplay() }}</span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">本月 ROI<span class="kpi-suffix">MTD</span></div>
<div class="kpi-value tabular-nums" :style="{ color: roiColor(monthAgg?.roiValue) }">
{{ fmtPercent(monthAgg?.roiValue) }}
</div>
<div class="kpi-sub" v-if="monthAgg">成本 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalCost) }}</span> · 产出 <span class="tabular-nums">{{ fmtCurrency(monthAgg.totalRevenue) }}</span></div>
</article>
</div>
<!-- YTD 摘要(单行替代 chip) -->
<div v-if="ytdAgg" class="ytd-strip">
<span class="ytd-label">YTD 本年</span>
<span class="ytd-metric"><span class="ytd-key">ROI</span><span class="ytd-num tabular-nums" :style="{ color: roiColor(ytdAgg.roiValue) }">{{ fmtPercent(ytdAgg.roiValue) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">成本</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalCost) }}</span></span>
<span class="ytd-sep"></span>
<span class="ytd-metric"><span class="ytd-key">产出</span><span class="ytd-num tabular-nums">{{ fmtCurrency(ytdAgg.totalRevenue) }}</span></span>
</div>
<!-- 折线图 -->
<NCard size="small" style="margin-top:16px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>成本 vs 产出 趋势</span>
<NSelect v-model:value="granularity" :options="[
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' },
]" style="width:90px" size="small" />
</div>
</template>
<RoiTimeSeriesChart v-if="tsBuckets.length > 0" :buckets="tsBuckets" :granularity="granularity" />
<NEmpty v-else description="暂无数据" />
</NCard>
<!-- 驱动因子(独占一行)-->
<NCard size="small" title="价值驱动因子(AI 解读)" style="margin-top:16px">
<div v-if="driverFactors.length > 0">
<div v-for="(f, i) in driverFactors" :key="i" class="factor-row">
<NTag :type="f.type === '现金流驱动' ? 'success' : f.type === '降本增效驱动' ? 'info' : 'warning'" size="small">{{ f.type }}</NTag>
<span style="margin-left:8px">{{ f.text }}</span>
</div>
</div>
<NEmpty v-else description="月度 AI 解读暂无(每月 1 号自动生成)" />
</NCard>
<!-- 事件流明细:成本 + 产出 并排 -->
<div class="two-col" style="margin-top:16px">
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💸 成本事件流</span>
<span class="event-count">{{ recentCostEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentCostEvents.length > 0">
<NTimelineItem
v-for="e in recentCostEvents.slice(0, 20)"
:key="e.id"
type="error"
:title="`${e.costType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无成本事件" />
</NCard>
<NCard size="small">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span>💰 产出事件流</span>
<span class="event-count">{{ recentRevenueEvents.length }} </span>
</div>
</template>
<NTimeline v-if="recentRevenueEvents.length > 0">
<NTimelineItem
v-for="e in recentRevenueEvents.slice(0, 20)"
:key="e.id"
:type="e.amount < 0 ? 'warning' : 'success'"
:title="`${e.revenueType} · ${fmtCurrency(e.amount)}`"
:content="e.notes || ''"
:time="dayjs(e.eventDate).format('YYYY-MM-DD') + (e.dataSource ? ` · ${e.dataSource}` : '')"
/>
</NTimeline>
<NEmpty v-else description="暂无产出事件" />
</NCard>
</div>
</NSpin>
<EventEntryModal v-model:show="showEntryModal" :type="entryModalType" :project-id="projectId" @saved="loadAll" />
</div>
</template>
<style scoped>
.roi-board-page {
padding: var(--space-2) 0 clamp(var(--space-8), 4vw, var(--space-16));
width: 100%;
}
/* ─────── Editorial 页头 ─────── */
.board-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: var(--space-6);
padding-bottom: var(--space-6);
margin-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.header-left { min-width: 0; flex: 1; }
.back-link {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 10px;
font-size: var(--text-sm);
color: var(--color-text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-1);
transition: all var(--duration-fast) var(--ease-out);
margin-bottom: var(--space-3);
}
.back-link:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); background: var(--color-bg-subtle); }
.eyebrow {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.board-title {
font-family: var(--font-sans);
font-size: var(--text-display);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-primary);
line-height: var(--leading-snug);
margin: 0;
}
.board-lede {
margin-top: var(--space-3);
font-size: var(--text-md);
color: var(--color-text-secondary);
line-height: var(--leading-normal);
}
.header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
.action-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
border: 1px solid;
}
.action-btn-secondary {
background: var(--color-bg-card);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.action-btn-secondary:hover { background: var(--color-bg-subtle); border-color: var(--color-border-strong); }
.action-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-onDark);
}
.action-btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); }
/* ─────── KPI 卡片 ─────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
@media (min-width: 1280px) {
.kpi-grid { grid-template-columns: 1.3fr 1fr 1.3fr 1fr; }
}
.kpi-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: var(--space-6);
transition: border-color var(--duration-base) var(--ease-out),
box-shadow var(--duration-base) var(--ease-out),
transform var(--duration-base) var(--ease-out);
}
.kpi-card:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.kpi-card.kpi-card-highlight {
border-left: 3px solid var(--color-primary);
background: var(--color-bg-card);
}
.kpi-card.kpi-card-highlight .kpi-value {
font-size: clamp(40px, 3.6vw, 52px);
letter-spacing: -0.035em;
}
.kpi-label {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.kpi-suffix {
font-size: 10px;
color: var(--color-text-muted);
padding: 1px 5px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
font-weight: var(--weight-regular);
}
.kpi-value {
font-family: var(--font-mono);
font-size: 32px;
font-weight: var(--weight-semibold);
letter-spacing: -0.025em;
line-height: 1;
margin-top: var(--space-3);
}
.kpi-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-subtle);
line-height: var(--leading-snug);
}
.kpi-sub-confidence {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.kpi-sub-warn { color: var(--color-error); }
/* ─────── YTD 简单 strip(节奏化:小标签 + 大数字) ─────── */
.ytd-strip {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-5);
padding: var(--space-3) var(--space-5);
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
flex-wrap: wrap;
}
.ytd-label {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.ytd-metric {
display: inline-flex;
gap: var(--space-2);
align-items: baseline;
}
.ytd-key {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ytd-num {
font-size: 15px;
font-weight: var(--weight-semibold);
color: var(--color-text-primary);
}
.ytd-sep {
display: inline-block;
width: 1px;
height: 12px;
background: var(--color-border);
}
/* ─────── 通用 ─────── */
.two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: clamp(var(--space-3), 1vw, var(--space-5));
}
.factor-row { margin: var(--space-2) 0; line-height: var(--leading-normal); display: flex; align-items: flex-start; gap: var(--space-2); }
.event-count {
font-size: var(--text-xs);
color: var(--color-text-muted);
background: var(--color-bg-subtle);
padding: 2px 10px;
border-radius: var(--radius-full);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-medium);
}
/* auto-fit + minmax 已经处理窄屏自适应,只在很窄时调整 header 布局 */
@media (max-width: 640px) {
.board-header { flex-direction: column; align-items: flex-start; }
.header-actions { width: 100%; justify-content: flex-end; }
}
</style>

View File

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

Binary file not shown.