/** * services/sample-data.js —— 五端模拟视图的独立数据服务(仅演示)。 * * 隔离原则(重要): * 1. 数据源是 preview-seed.json(334 条基线),不读线上、不读生产库。 * 2. 一切写入(改状态 / 评分 / 评论)只落在内存 + 独立 localStorage 命名空间 * `kf-sample::`,绝不调用 /classes/* 或 /api/functions。 * 3. 与 board-service 暴露同名方法,页面可以复用同一套交互组件, * 但底层不是生产写路径 —— 这是为了「抽屉能点,但不会动线上数据」。 * 4. 提供 resetSample() 一键还原,演示前可清场。 */ const SEED_URL = new URL('../../data/preview-seed.json', import.meta.url).href; const LS_KEY = 'kf-sample::mutations::v1'; export const SAMPLE_DATASET_VERSION = 'ksf.innovation-preview-seed.v1'; export const SAMPLE_BASELINE_COUNT = 334; let seedCache = null; let seeded = null; /** 模块级共享引用:视图加载后写这里,抽屉直接读,避免事件时序问题 */ let currentModel = null; export function setCurrentModel(m) { currentModel = m; } export function getCurrentModel() { return currentModel; } /** 读种子(浏览器端缓存;失败抛错,不兜底造数据) */ export async function loadSeed() { if (seedCache) return seedCache; const res = await fetch(SEED_URL, { cache: 'no-cache' }); if (!res.ok) throw new Error(`种子读取失败 HTTP ${res.status}`); seedCache = await res.json(); return seedCache; } /** 本地改动(状态 / 评分 / 评论),命名空间隔离,可清空 */ function readMutations() { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } } function writeMutations(m) { localStorage.setItem(LS_KEY, JSON.stringify(m)); } export function resetSample() { localStorage.removeItem(LS_KEY); } export function hasSampleMutations() { return Object.keys(readMutations()).length > 0; } /** * 把种子适配成界面模型(五端统一用这一份)。 * 只做字段改名与派生,不改数量、不引入种子外的对象。 */ export function adaptSeed(seed) { if (seeded) return seeded; const mut = readMutations(); const groups = (seed.groups || []).map((g) => ({ id: g.id, name: g.name || g.id, memberIds: g.memberIds || [], projectIds: g.projectIds || [], })); // 成员:种子里没有 stuck / stagesSubmitted 字段 —— 不造。 // 「交付了哪些阶段」由 reports 按 groupId + stageCode 实算(见下方 reportsByGroup)。 const members = (seed.members || []).map((m) => ({ id: m.id, name: m.displayName || m.name || m.id, role: m.role || '', groupId: m.groupId || '', aiFamiliarity: m.aiFamiliarity || '', })); // 机会卡:状态/评分以本地改动为优先(仅演示) const opportunities = (seed.opportunities || []).map((o) => { const patch = mut.opportunities?.[o.id] || {}; const scores = patch.scores || o.scores || []; const n = scores.length; const avg = n ? Math.round(scores.reduce((s, x) => s + (Number(x.score) || 0), 0) / n * 10) / 10 : null; return { id: o.id, title: o.title || '(未命名)', oneLiner: o.oneLiner || o.demandSummary || '', demandSummary: o.demandSummary || '', status: patch.status || o.status || 'candidate', statusRaw: patch.status || o.status || 'candidate', stageCode: o.stageCode || '', category: o.category || '', usageScenario: o.usageScenario || '', targetAudience: o.targetAudience || '', claimKind: o.claimKind || 'hypothesis', priority: o.priority || '', evidenceIds: o.evidenceIds || [], quoteCount: o.quoteCount != null ? o.quoteCount : (o.evidenceIds || []).length, citeCount: o.citeCount || 0, projectId: o.projectId || '', groupId: o.groupId || '', ownerName: o.ownerName || '', scores, stats: n ? { n, avg, min: Math.min(...scores.map((s) => Number(s.score))), max: Math.max(...scores.map((s) => Number(s.score))), latest: scores[n - 1] } : null, decision: o.decision || null, decisionLog: o.decisionLog || [], }; }); // 项目:阶段 / 状态用种子原值 const projects = (seed.projects || []).map((p) => ({ id: p.id, title: p.title || p.name || '(未命名项目)', groupId: p.groupId || '', ownerMemberId: p.ownerMemberId || '', ownerName: (seed.members || []).find((x) => x.id === p.ownerMemberId)?.displayName || '', targetPrice: p.targetPrice || null, enterpriseApproval: !!p.enterpriseApproval, formalReportPublished: !!p.formalReportPublished, stageCode: p.stageCode || '', stage: { code: p.stageCode || '', label: p.stageLabel || p.stageCode || '' }, progressState: p.progressState || p.state || '', stateLabel: p.stateLabel || '', usageScenario: p.usageScenario || '', knownRisk: p.knownRisk || '', questionIds: p.questionIds || [], artifactIds: p.artifactIds || [], opportunityId: p.opportunityId || '', })); const sources = (seed.sources || []).map((s) => ({ id: s.id, title: s.title || '(未命名来源)', platform: s.platform || '', authorLabel: s.authorLabel || '', verificationState: s.verificationState || '', kind: s.kind || '', })); const evidence = seed.evidence || []; const reports = (seed.reports || []).map((r) => ({ id: r.id, title: r.title || '', stageCode: r.stageCode || '', projectId: r.projectId || '', groupId: r.groupId || '', dataMode: r.dataMode || 'demo_only', version: r.version || 1, })); const questions = (seed.questions || []).map((q) => ({ id: q.id, text: q.text || q.title || '', projectId: q.projectId || '', status: q.status || 'open', })); const validationTasks = (seed.validationTasks || []).map((v) => ({ id: v.id, projectId: v.projectId || '', state: v.state || '', title: v.title || '', })); const actual = seed.actualSeedCounts || {}; const baselineCount = ['groups','members','ideas','sources','evidence','opportunities','projects','conceptVersions','reports','questions','validationTasks','assignments'] .reduce((n, k) => n + (Array.isArray(seed[k]) ? seed[k].length : 0), 0); // 按组×阶段实算交付情况(种子无 stagesSubmitted,这是唯一的真实来源) const reportsByGroup = {}; const reportsByStage = {}; for (const r of reports) { const g = r.groupId || '未分组'; reportsByGroup[g] = reportsByGroup[g] || { total: 0, stages: {} }; reportsByGroup[g].total += 1; reportsByGroup[g].stages[r.stageCode] = (reportsByGroup[g].stages[r.stageCode] || 0) + 1; reportsByStage[r.stageCode] = (reportsByStage[r.stageCode] || 0) + 1; } // 半场作业状态(assignments 真实字段) const assignments = (seed.assignments || []).map((a) => ({ id: a.id, groupId: a.groupId || '', halfDay: a.halfDay || 1, state: a.state || '', reportIds: a.reportIds || [], deliverableTypes: a.deliverableTypes || [], })); seeded = { datasetVersion: seed.version || SAMPLE_DATASET_VERSION, reportsByGroup, reportsByStage, assignmentsReal: assignments, baselineEntityCount: baselineCount, groups, members, opportunities, projects, sources, evidence, reports, questions, validationTasks, ideas: seed.ideas || [], conceptVersions: seed.conceptVersions || [], assignments: assignments, projectStageCounts: seed.projectStageCounts || {}, projectStateCounts: seed.projectStateCounts || {}, actualSeedCounts: actual, opportunityById: Object.fromEntries(opportunities.map((o) => [o.id, o])), }; return seeded; } /** 便捷:加载 + 适配 */ export async function loadSampleModel() { const seed = await loadSeed(); return adaptSeed(seed); } /** * 模拟写操作(与 board-service 同名同形,但只写本地命名空间)。 * 调用方:详情抽屉在模拟模式下注入本对象,替代生产 boardService。 */ export const sampleWriter = { mode: 'sample', setOpportunityStatus(id, status, { by = '演示', reason = '' } = {}) { const m = readMutations(); m.opportunities = m.opportunities || {}; m.opportunities[id] = { ...(m.opportunities[id] || {}), status, by, reason, at: new Date().toISOString() }; writeMutations(m); seeded = null; return { changed: true, scope: 'sample' }; }, rateOpportunity(id, { user = '演示', score, comment = '' }) { const m = readMutations(); m.opportunities = m.opportunities || {}; const rec = m.opportunities[id] || {}; const scores = (rec.scores || []).filter((s) => s.user !== user); scores.push({ user, score: Number(score), comment, at: new Date().toISOString() }); m.opportunities[id] = { ...rec, scores }; writeMutations(m); seeded = null; return { scope: 'sample', n: scores.length }; }, };