sample-data.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. /**
  2. * services/sample-data.js —— 五端模拟视图的独立数据服务(仅演示)。
  3. *
  4. * 隔离原则(重要):
  5. * 1. 数据源是 preview-seed.json(334 条基线),不读线上、不读生产库。
  6. * 2. 一切写入(改状态 / 评分 / 评论)只落在内存 + 独立 localStorage 命名空间
  7. * `kf-sample::`,绝不调用 /classes/* 或 /api/functions。
  8. * 3. 与 board-service 暴露同名方法,页面可以复用同一套交互组件,
  9. * 但底层不是生产写路径 —— 这是为了「抽屉能点,但不会动线上数据」。
  10. * 4. 提供 resetSample() 一键还原,演示前可清场。
  11. */
  12. const SEED_URL = new URL('../../data/preview-seed.json', import.meta.url).href;
  13. const LS_KEY = 'kf-sample::mutations::v1';
  14. export const SAMPLE_DATASET_VERSION = 'ksf.innovation-preview-seed.v1';
  15. export const SAMPLE_BASELINE_COUNT = 334;
  16. let seedCache = null;
  17. let seeded = null;
  18. /** 模块级共享引用:视图加载后写这里,抽屉直接读,避免事件时序问题 */
  19. let currentModel = null;
  20. export function setCurrentModel(m) { currentModel = m; }
  21. export function getCurrentModel() { return currentModel; }
  22. /** 读种子(浏览器端缓存;失败抛错,不兜底造数据) */
  23. export async function loadSeed() {
  24. if (seedCache) return seedCache;
  25. const res = await fetch(SEED_URL, { cache: 'no-cache' });
  26. if (!res.ok) throw new Error(`种子读取失败 HTTP ${res.status}`);
  27. seedCache = await res.json();
  28. return seedCache;
  29. }
  30. /** 本地改动(状态 / 评分 / 评论),命名空间隔离,可清空 */
  31. function readMutations() {
  32. try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; }
  33. }
  34. function writeMutations(m) {
  35. localStorage.setItem(LS_KEY, JSON.stringify(m));
  36. }
  37. export function resetSample() {
  38. localStorage.removeItem(LS_KEY);
  39. }
  40. export function hasSampleMutations() {
  41. return Object.keys(readMutations()).length > 0;
  42. }
  43. /**
  44. * 把种子适配成界面模型(五端统一用这一份)。
  45. * 只做字段改名与派生,不改数量、不引入种子外的对象。
  46. */
  47. export function adaptSeed(seed) {
  48. if (seeded) return seeded;
  49. const mut = readMutations();
  50. const groups = (seed.groups || []).map((g) => ({
  51. id: g.id, name: g.name || g.id,
  52. memberIds: g.memberIds || [], projectIds: g.projectIds || [],
  53. }));
  54. // 成员:种子里没有 stuck / stagesSubmitted 字段 —— 不造。
  55. // 「交付了哪些阶段」由 reports 按 groupId + stageCode 实算(见下方 reportsByGroup)。
  56. const members = (seed.members || []).map((m) => ({
  57. id: m.id, name: m.displayName || m.name || m.id,
  58. role: m.role || '', groupId: m.groupId || '',
  59. aiFamiliarity: m.aiFamiliarity || '',
  60. }));
  61. // 机会卡:状态/评分以本地改动为优先(仅演示)
  62. const opportunities = (seed.opportunities || []).map((o) => {
  63. const patch = mut.opportunities?.[o.id] || {};
  64. const scores = patch.scores || o.scores || [];
  65. const n = scores.length;
  66. const avg = n ? Math.round(scores.reduce((s, x) => s + (Number(x.score) || 0), 0) / n * 10) / 10 : null;
  67. return {
  68. id: o.id,
  69. title: o.title || '(未命名)',
  70. oneLiner: o.oneLiner || o.demandSummary || '',
  71. demandSummary: o.demandSummary || '',
  72. status: patch.status || o.status || 'candidate',
  73. statusRaw: patch.status || o.status || 'candidate',
  74. stageCode: o.stageCode || '',
  75. category: o.category || '',
  76. usageScenario: o.usageScenario || '',
  77. targetAudience: o.targetAudience || '',
  78. claimKind: o.claimKind || 'hypothesis',
  79. priority: o.priority || '',
  80. evidenceIds: o.evidenceIds || [],
  81. quoteCount: o.quoteCount != null ? o.quoteCount : (o.evidenceIds || []).length,
  82. citeCount: o.citeCount || 0,
  83. projectId: o.projectId || '',
  84. groupId: o.groupId || '',
  85. ownerName: o.ownerName || '',
  86. scores,
  87. 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,
  88. decision: o.decision || null,
  89. decisionLog: o.decisionLog || [],
  90. };
  91. });
  92. // 项目:阶段 / 状态用种子原值
  93. const projects = (seed.projects || []).map((p) => ({
  94. id: p.id,
  95. title: p.title || p.name || '(未命名项目)',
  96. groupId: p.groupId || '',
  97. ownerMemberId: p.ownerMemberId || '',
  98. ownerName: (seed.members || []).find((x) => x.id === p.ownerMemberId)?.displayName || '',
  99. targetPrice: p.targetPrice || null,
  100. enterpriseApproval: !!p.enterpriseApproval,
  101. formalReportPublished: !!p.formalReportPublished,
  102. stageCode: p.stageCode || '',
  103. stage: { code: p.stageCode || '', label: p.stageLabel || p.stageCode || '' },
  104. progressState: p.progressState || p.state || '',
  105. stateLabel: p.stateLabel || '',
  106. usageScenario: p.usageScenario || '',
  107. knownRisk: p.knownRisk || '',
  108. questionIds: p.questionIds || [],
  109. artifactIds: p.artifactIds || [],
  110. opportunityId: p.opportunityId || '',
  111. }));
  112. const sources = (seed.sources || []).map((s) => ({
  113. id: s.id, title: s.title || '(未命名来源)',
  114. platform: s.platform || '', authorLabel: s.authorLabel || '',
  115. verificationState: s.verificationState || '', kind: s.kind || '',
  116. }));
  117. const evidence = seed.evidence || [];
  118. const reports = (seed.reports || []).map((r) => ({
  119. id: r.id, title: r.title || '', stageCode: r.stageCode || '',
  120. projectId: r.projectId || '', groupId: r.groupId || '',
  121. dataMode: r.dataMode || 'demo_only', version: r.version || 1,
  122. }));
  123. const questions = (seed.questions || []).map((q) => ({
  124. id: q.id, text: q.text || q.title || '', projectId: q.projectId || '', status: q.status || 'open',
  125. }));
  126. const validationTasks = (seed.validationTasks || []).map((v) => ({
  127. id: v.id, projectId: v.projectId || '', state: v.state || '', title: v.title || '',
  128. }));
  129. const actual = seed.actualSeedCounts || {};
  130. const baselineCount = ['groups','members','ideas','sources','evidence','opportunities','projects','conceptVersions','reports','questions','validationTasks','assignments']
  131. .reduce((n, k) => n + (Array.isArray(seed[k]) ? seed[k].length : 0), 0);
  132. // 按组×阶段实算交付情况(种子无 stagesSubmitted,这是唯一的真实来源)
  133. const reportsByGroup = {};
  134. const reportsByStage = {};
  135. for (const r of reports) {
  136. const g = r.groupId || '未分组';
  137. reportsByGroup[g] = reportsByGroup[g] || { total: 0, stages: {} };
  138. reportsByGroup[g].total += 1;
  139. reportsByGroup[g].stages[r.stageCode] = (reportsByGroup[g].stages[r.stageCode] || 0) + 1;
  140. reportsByStage[r.stageCode] = (reportsByStage[r.stageCode] || 0) + 1;
  141. }
  142. // 半场作业状态(assignments 真实字段)
  143. const assignments = (seed.assignments || []).map((a) => ({
  144. id: a.id, groupId: a.groupId || '', halfDay: a.halfDay || 1,
  145. state: a.state || '', reportIds: a.reportIds || [],
  146. deliverableTypes: a.deliverableTypes || [],
  147. }));
  148. seeded = {
  149. datasetVersion: seed.version || SAMPLE_DATASET_VERSION,
  150. reportsByGroup, reportsByStage, assignmentsReal: assignments,
  151. baselineEntityCount: baselineCount,
  152. groups, members, opportunities, projects, sources, evidence, reports, questions, validationTasks,
  153. ideas: seed.ideas || [], conceptVersions: seed.conceptVersions || [], assignments: assignments,
  154. projectStageCounts: seed.projectStageCounts || {}, projectStateCounts: seed.projectStateCounts || {},
  155. actualSeedCounts: actual,
  156. opportunityById: Object.fromEntries(opportunities.map((o) => [o.id, o])),
  157. };
  158. return seeded;
  159. }
  160. /** 便捷:加载 + 适配 */
  161. export async function loadSampleModel() {
  162. const seed = await loadSeed();
  163. return adaptSeed(seed);
  164. }
  165. /**
  166. * 模拟写操作(与 board-service 同名同形,但只写本地命名空间)。
  167. * 调用方:详情抽屉在模拟模式下注入本对象,替代生产 boardService。
  168. */
  169. export const sampleWriter = {
  170. mode: 'sample',
  171. setOpportunityStatus(id, status, { by = '演示', reason = '' } = {}) {
  172. const m = readMutations();
  173. m.opportunities = m.opportunities || {};
  174. m.opportunities[id] = { ...(m.opportunities[id] || {}), status, by, reason, at: new Date().toISOString() };
  175. writeMutations(m);
  176. seeded = null;
  177. return { changed: true, scope: 'sample' };
  178. },
  179. rateOpportunity(id, { user = '演示', score, comment = '' }) {
  180. const m = readMutations();
  181. m.opportunities = m.opportunities || {};
  182. const rec = m.opportunities[id] || {};
  183. const scores = (rec.scores || []).filter((s) => s.user !== user);
  184. scores.push({ user, score: Number(score), comment, at: new Date().toISOString() });
  185. m.opportunities[id] = { ...rec, scores };
  186. writeMutations(m);
  187. seeded = null;
  188. return { scope: 'sample', n: scores.length };
  189. },
  190. };