build-voc-community-course-decks.mjs 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094
  1. import fs from "node:fs/promises";
  2. import path from "node:path";
  3. import { spawnSync } from "node:child_process";
  4. import { pathToFileURL } from "node:url";
  5. const ROOT = path.resolve("E:/workspace/openclaw-voc-skill");
  6. const OUT_DIR = path.join(ROOT, "docs", "voc-skill-student-course");
  7. const WORKSPACE = path.join(
  8. ROOT,
  9. "outputs",
  10. process.env.CODEX_THREAD_ID || `manual-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}`,
  11. "presentations",
  12. "voc-community-course-two-lessons",
  13. );
  14. const PREVIEW_DIR = path.join(WORKSPACE, "preview");
  15. const LAYOUT_DIR = path.join(WORKSPACE, "layout");
  16. const QA_DIR = path.join(WORKSPACE, "qa");
  17. const NODE_HOME = process.env.USERPROFILE || process.env.HOME;
  18. const ARTIFACT_TOOL = path.join(
  19. NODE_HOME,
  20. ".cache",
  21. "codex-runtimes",
  22. "codex-primary-runtime",
  23. "dependencies",
  24. "node",
  25. "node_modules",
  26. "@oai",
  27. "artifact-tool",
  28. "dist",
  29. "artifact_tool.mjs",
  30. );
  31. const PYTHON =
  32. process.env.PYTHON ||
  33. path.join(
  34. NODE_HOME,
  35. ".cache",
  36. "codex-runtimes",
  37. "codex-primary-runtime",
  38. "dependencies",
  39. "python",
  40. "python.exe",
  41. );
  42. const MAKE_CONTACT_SHEET = path.join(
  43. NODE_HOME,
  44. ".codex",
  45. "plugins",
  46. "cache",
  47. "openai-primary-runtime",
  48. "presentations",
  49. "26.601.10930",
  50. "skills",
  51. "presentations",
  52. "scripts",
  53. "make_contact_sheet.py",
  54. );
  55. const W = 1280;
  56. const H = 720;
  57. const palette = {
  58. porcelain: "#F8FBFF",
  59. paper: "#FFFFFF",
  60. ink: "#133B68",
  61. ink2: "#0A2440",
  62. blue: "#1E68A8",
  63. mist: "#E8F2FC",
  64. mist2: "#F1F7FC",
  65. jade: "#1F7A72",
  66. jadeSoft: "#DDF2EE",
  67. cinnabar: "#C9513B",
  68. cinnabarSoft: "#F8E5DE",
  69. gold: "#B88932",
  70. charcoal: "#13202B",
  71. muted: "#5F7082",
  72. line: "#C7D9EA",
  73. dark: "#0F1A24",
  74. };
  75. const FONT = {
  76. title: "Microsoft YaHei UI",
  77. body: "Microsoft YaHei",
  78. mono: "Cascadia Mono",
  79. };
  80. function line(fill = "#00000000", width = 0, style = "solid") {
  81. return { style, fill, width };
  82. }
  83. function addShape(slide, options) {
  84. const {
  85. geometry = "rect",
  86. fill = "#00000000",
  87. border = "#00000000",
  88. borderWidth = 0,
  89. left,
  90. top,
  91. width,
  92. height,
  93. name,
  94. } = options;
  95. return slide.shapes.add({
  96. geometry,
  97. name,
  98. position: { left, top, width, height },
  99. fill,
  100. line: line(border, borderWidth),
  101. });
  102. }
  103. function addText(slide, options) {
  104. const {
  105. text,
  106. left,
  107. top,
  108. width,
  109. height,
  110. size = 24,
  111. color = palette.charcoal,
  112. bold = false,
  113. typeface = FONT.body,
  114. align = "left",
  115. valign = "top",
  116. fill = "#00000000",
  117. border = "#00000000",
  118. borderWidth = 0,
  119. insets = { left: 0, right: 0, top: 0, bottom: 0 },
  120. } = options;
  121. const shape = addShape(slide, {
  122. left,
  123. top,
  124. width,
  125. height,
  126. fill,
  127. border,
  128. borderWidth,
  129. });
  130. shape.text = text;
  131. shape.text.fontSize = size;
  132. shape.text.color = color;
  133. shape.text.bold = bold;
  134. shape.text.typeface = typeface;
  135. shape.text.alignment = align;
  136. shape.text.verticalAlignment = valign;
  137. shape.text.insets = insets;
  138. return shape;
  139. }
  140. function addFooter(slide, deckLabel, index, total) {
  141. addText(slide, {
  142. text: deckLabel,
  143. left: 64,
  144. top: 668,
  145. width: 600,
  146. height: 22,
  147. size: 13,
  148. color: palette.muted,
  149. });
  150. addText(slide, {
  151. text: `${String(index).padStart(2, "0")} / ${String(total).padStart(2, "0")}`,
  152. left: 1112,
  153. top: 666,
  154. width: 100,
  155. height: 24,
  156. size: 14,
  157. color: palette.muted,
  158. typeface: FONT.mono,
  159. align: "right",
  160. });
  161. }
  162. function addTitle(slide, title, kicker, subtitle) {
  163. if (kicker) {
  164. addText(slide, {
  165. text: kicker,
  166. left: 64,
  167. top: 46,
  168. width: 680,
  169. height: 28,
  170. size: 16,
  171. bold: true,
  172. color: palette.jade,
  173. });
  174. }
  175. addText(slide, {
  176. text: title,
  177. left: 64,
  178. top: kicker ? 82 : 58,
  179. width: 860,
  180. height: 88,
  181. size: 38,
  182. bold: true,
  183. color: palette.ink2,
  184. typeface: FONT.title,
  185. });
  186. if (subtitle) {
  187. addText(slide, {
  188. text: subtitle,
  189. left: 66,
  190. top: 174,
  191. width: 780,
  192. height: 44,
  193. size: 18,
  194. color: palette.muted,
  195. });
  196. }
  197. }
  198. function bg(slide, tone = "light") {
  199. addShape(slide, { left: 0, top: 0, width: W, height: H, fill: tone === "dark" ? palette.ink2 : palette.porcelain });
  200. if (tone !== "dark") {
  201. addShape(slide, { left: 0, top: 0, width: 24, height: H, fill: palette.ink });
  202. addShape(slide, { left: 24, top: 0, width: 6, height: H, fill: palette.jade });
  203. }
  204. }
  205. function cover(presentation, spec, ctx) {
  206. const slide = presentation.slides.add();
  207. bg(slide, "light");
  208. addShape(slide, { left: 760, top: 0, width: 520, height: H, fill: spec.accent || palette.ink });
  209. addShape(slide, { left: 820, top: 78, width: 330, height: 330, fill: "#FFFFFF12", border: "#FFFFFF22", borderWidth: 1 });
  210. addShape(slide, { left: 880, top: 138, width: 210, height: 210, fill: "#FFFFFF12", border: "#FFFFFF2A", borderWidth: 1 });
  211. addText(slide, {
  212. text: spec.kicker,
  213. left: 70,
  214. top: 78,
  215. width: 560,
  216. height: 30,
  217. size: 17,
  218. bold: true,
  219. color: palette.jade,
  220. });
  221. addText(slide, {
  222. text: spec.title,
  223. left: 68,
  224. top: 128,
  225. width: 610,
  226. height: 170,
  227. size: 54,
  228. bold: true,
  229. color: palette.ink2,
  230. typeface: FONT.title,
  231. });
  232. addText(slide, {
  233. text: spec.subtitle,
  234. left: 72,
  235. top: 330,
  236. width: 585,
  237. height: 72,
  238. size: 23,
  239. color: palette.charcoal,
  240. });
  241. addText(slide, {
  242. text: spec.rightTitle,
  243. left: 820,
  244. top: 468,
  245. width: 330,
  246. height: 64,
  247. size: 32,
  248. bold: true,
  249. color: palette.paper,
  250. typeface: FONT.title,
  251. });
  252. addText(slide, {
  253. text: spec.rightText,
  254. left: 822,
  255. top: 545,
  256. width: 330,
  257. height: 84,
  258. size: 18,
  259. color: "#D8EAF8",
  260. });
  261. addFooter(slide, spec.footer, ctx.index, ctx.total);
  262. return slide;
  263. }
  264. function agenda(presentation, spec, ctx) {
  265. const slide = presentation.slides.add();
  266. bg(slide);
  267. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  268. const y = 260;
  269. const gap = 26;
  270. const w = (1088 - gap * (spec.items.length - 1)) / spec.items.length;
  271. spec.items.forEach((item, i) => {
  272. const x = 76 + i * (w + gap);
  273. addShape(slide, { left: x, top: y, width: w, height: 245, fill: item.fill || palette.paper, border: palette.line, borderWidth: 1 });
  274. addText(slide, {
  275. text: `0${i + 1}`,
  276. left: x + 24,
  277. top: y + 22,
  278. width: 90,
  279. height: 58,
  280. size: 48,
  281. bold: true,
  282. color: item.accent || palette.blue,
  283. typeface: FONT.mono,
  284. });
  285. addText(slide, {
  286. text: item.title,
  287. left: x + 24,
  288. top: y + 96,
  289. width: w - 48,
  290. height: 54,
  291. size: 25,
  292. bold: true,
  293. color: palette.ink2,
  294. typeface: FONT.title,
  295. });
  296. addText(slide, {
  297. text: item.text,
  298. left: x + 24,
  299. top: y + 164,
  300. width: w - 48,
  301. height: 58,
  302. size: 17,
  303. color: palette.muted,
  304. });
  305. });
  306. addFooter(slide, spec.footer, ctx.index, ctx.total);
  307. return slide;
  308. }
  309. function claim(presentation, spec, ctx) {
  310. const slide = presentation.slides.add();
  311. bg(slide, spec.dark ? "dark" : "light");
  312. const c = spec.dark ? palette.paper : palette.ink2;
  313. const muted = spec.dark ? "#C8D9E8" : palette.muted;
  314. addText(slide, { text: spec.kicker || "", left: 72, top: 56, width: 460, height: 28, size: 16, bold: true, color: spec.dark ? palette.jadeSoft : palette.jade });
  315. addText(slide, { text: spec.title, left: 70, top: 96, width: 740, height: 150, size: spec.titleSize || 46, bold: true, color: c, typeface: FONT.title });
  316. addText(slide, { text: spec.text, left: 74, top: 252, width: 600, height: 88, size: 22, color: muted });
  317. addShape(slide, { left: 770, top: 112, width: 360, height: 360, fill: spec.blockFill || (spec.dark ? "#FFFFFF10" : palette.paper), border: spec.dark ? "#FFFFFF22" : palette.line, borderWidth: 1 });
  318. addText(slide, { text: spec.proofTitle, left: 812, top: 158, width: 276, height: 50, size: 27, bold: true, color: c, typeface: FONT.title, align: "center" });
  319. addText(slide, { text: spec.proofText, left: 820, top: 232, width: 260, height: 164, size: 20, color: muted, align: "center", valign: "mid" });
  320. addShape(slide, { left: 812, top: 426, width: 276, height: 8, fill: spec.accent || palette.cinnabar });
  321. addFooter(slide, spec.footer, ctx.index, ctx.total);
  322. return slide;
  323. }
  324. function compare(presentation, spec, ctx) {
  325. const slide = presentation.slides.add();
  326. bg(slide);
  327. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  328. const colW = spec.columns.length === 2 ? 510 : 340;
  329. const startX = spec.columns.length === 2 ? 108 : 86;
  330. const gap = spec.columns.length === 2 ? 42 : 28;
  331. spec.columns.forEach((col, i) => {
  332. const x = startX + i * (colW + gap);
  333. addShape(slide, { left: x, top: 232, width: colW, height: 348, fill: col.fill || palette.paper, border: col.border || palette.line, borderWidth: 1 });
  334. addShape(slide, { left: x, top: 232, width: colW, height: 12, fill: col.accent || palette.blue });
  335. addText(slide, { text: col.title, left: x + 28, top: 270, width: colW - 56, height: 48, size: 28, bold: true, color: col.accent || palette.ink2, typeface: FONT.title });
  336. addText(slide, { text: col.body, left: x + 30, top: 340, width: colW - 60, height: 155, size: 19, color: palette.charcoal });
  337. addText(slide, { text: col.bottom, left: x + 30, top: 518, width: colW - 60, height: 36, size: 17, color: palette.muted });
  338. });
  339. addFooter(slide, spec.footer, ctx.index, ctx.total);
  340. return slide;
  341. }
  342. function flow(presentation, spec, ctx) {
  343. const slide = presentation.slides.add();
  344. bg(slide);
  345. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  346. const n = spec.steps.length;
  347. const startX = 78;
  348. const y = 278;
  349. const boxW = (1090 - (n - 1) * 18) / n;
  350. spec.steps.forEach((step, i) => {
  351. const x = startX + i * (boxW + 18);
  352. addShape(slide, { left: x, top: y, width: boxW, height: 190, fill: step.fill || palette.paper, border: palette.line, borderWidth: 1 });
  353. addText(slide, { text: String(i + 1).padStart(2, "0"), left: x + 18, top: y + 18, width: 60, height: 32, size: 22, bold: true, color: step.accent || palette.blue, typeface: FONT.mono });
  354. addText(slide, { text: step.title, left: x + 18, top: y + 64, width: boxW - 36, height: 56, size: 21, bold: true, color: palette.ink2 });
  355. addText(slide, { text: step.text, left: x + 18, top: y + 126, width: boxW - 36, height: 40, size: 14, color: palette.muted });
  356. if (i < n - 1) {
  357. addText(slide, { text: "→", left: x + boxW + 2, top: y + 70, width: 18, height: 28, size: 24, bold: true, color: palette.line, align: "center" });
  358. }
  359. });
  360. if (spec.bottom) {
  361. addText(slide, { text: spec.bottom, left: 100, top: 530, width: 1040, height: 40, size: 20, bold: true, color: palette.cinnabar, align: "center" });
  362. }
  363. addFooter(slide, spec.footer, ctx.index, ctx.total);
  364. return slide;
  365. }
  366. function matrix(presentation, spec, ctx) {
  367. const slide = presentation.slides.add();
  368. bg(slide);
  369. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  370. const x = 76;
  371. const y = 225;
  372. const w = 1120;
  373. const rowH = 66;
  374. const colW = [210, 290, 310, 310];
  375. addShape(slide, { left: x, top: y, width: w, height: rowH, fill: palette.ink, border: palette.ink, borderWidth: 1 });
  376. spec.headers.forEach((h, i) => {
  377. const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
  378. addText(slide, { text: h, left: left + 16, top: y + 18, width: colW[i] - 32, height: 28, size: 17, bold: true, color: palette.paper });
  379. });
  380. spec.rows.forEach((row, r) => {
  381. const top = y + rowH * (r + 1);
  382. addShape(slide, { left: x, top, width: w, height: rowH, fill: r % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
  383. row.forEach((cell, i) => {
  384. const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
  385. addText(slide, { text: cell, left: left + 14, top: top + 10, width: colW[i] - 28, height: rowH - 24, size: i === 0 ? 16 : 14, bold: i === 0, color: i === 0 ? palette.ink2 : palette.charcoal });
  386. });
  387. });
  388. addFooter(slide, spec.footer, ctx.index, ctx.total);
  389. return slide;
  390. }
  391. function prompt(presentation, spec, ctx) {
  392. const slide = presentation.slides.add();
  393. bg(slide);
  394. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  395. addShape(slide, { left: 82, top: 230, width: 1110, height: 350, fill: palette.dark, border: "#27394A", borderWidth: 1 });
  396. addShape(slide, { left: 82, top: 230, width: 1110, height: 50, fill: "#162A3D" });
  397. addShape(slide, { left: 106, top: 246, width: 12, height: 12, fill: palette.cinnabar });
  398. addShape(slide, { left: 128, top: 246, width: 12, height: 12, fill: palette.gold });
  399. addShape(slide, { left: 150, top: 246, width: 12, height: 12, fill: palette.jade });
  400. addText(slide, { text: spec.label || "Prompt / Command", left: 184, top: 241, width: 400, height: 22, size: 14, color: "#AFC6D8", typeface: FONT.mono });
  401. addText(slide, { text: spec.code, left: 112, top: 298, width: 1028, height: 248, size: spec.size || 18, color: "#ECF5FF", typeface: FONT.mono });
  402. addFooter(slide, spec.footer, ctx.index, ctx.total);
  403. return slide;
  404. }
  405. function checklist(presentation, spec, ctx) {
  406. const slide = presentation.slides.add();
  407. bg(slide);
  408. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  409. const x = 92;
  410. let y = 230;
  411. spec.items.forEach((item, i) => {
  412. addShape(slide, { left: x, top: y, width: 1040, height: 58, fill: i % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
  413. addText(slide, { text: item[0], left: x + 22, top: y + 15, width: 215, height: 26, size: 18, bold: true, color: palette.ink2 });
  414. addText(slide, { text: item[1], left: x + 265, top: y + 15, width: 420, height: 28, size: 17, color: palette.charcoal });
  415. addText(slide, { text: item[2], left: x + 720, top: y + 15, width: 290, height: 28, size: 16, color: palette.jade, bold: true });
  416. y += 66;
  417. });
  418. addFooter(slide, spec.footer, ctx.index, ctx.total);
  419. return slide;
  420. }
  421. function cards(presentation, spec, ctx) {
  422. const slide = presentation.slides.add();
  423. bg(slide);
  424. addTitle(slide, spec.title, spec.kicker, spec.subtitle);
  425. const cols = spec.cards.length === 4 ? 4 : 3;
  426. const gap = 24;
  427. const cardW = (1094 - gap * (cols - 1)) / cols;
  428. spec.cards.forEach((card, i) => {
  429. const row = Math.floor(i / cols);
  430. const col = i % cols;
  431. const x = 76 + col * (cardW + gap);
  432. const y = 230 + row * 168;
  433. addShape(slide, { left: x, top: y, width: cardW, height: 142, fill: card.fill || palette.paper, border: palette.line, borderWidth: 1 });
  434. addText(slide, { text: card.title, left: x + 24, top: y + 22, width: cardW - 48, height: 34, size: 21, bold: true, color: card.accent || palette.ink2 });
  435. addText(slide, { text: card.text, left: x + 24, top: y + 68, width: cardW - 48, height: 52, size: 16, color: palette.muted });
  436. });
  437. if (spec.note) {
  438. addText(slide, { text: spec.note, left: 96, top: 596, width: 1040, height: 28, size: 18, bold: true, color: palette.cinnabar, align: "center" });
  439. }
  440. addFooter(slide, spec.footer, ctx.index, ctx.total);
  441. return slide;
  442. }
  443. function section(presentation, spec, ctx) {
  444. const slide = presentation.slides.add();
  445. bg(slide, "dark");
  446. addText(slide, { text: spec.number, left: 74, top: 78, width: 200, height: 90, size: 74, bold: true, color: palette.jadeSoft, typeface: FONT.mono });
  447. addText(slide, { text: spec.title, left: 78, top: 210, width: 760, height: 120, size: 48, bold: true, color: palette.paper, typeface: FONT.title });
  448. addText(slide, { text: spec.text, left: 82, top: 360, width: 690, height: 72, size: 23, color: "#CADBEA" });
  449. addShape(slide, { left: 880, top: 120, width: 240, height: 420, fill: "#FFFFFF0F", border: "#FFFFFF22", borderWidth: 1 });
  450. addText(slide, { text: spec.side, left: 910, top: 206, width: 180, height: 230, size: 30, bold: true, color: palette.paper, align: "center", valign: "mid" });
  451. addFooter(slide, spec.footer, ctx.index, ctx.total);
  452. return slide;
  453. }
  454. const theorySlides = [
  455. {
  456. type: "cover",
  457. kicker: "理论课 01",
  458. title: "VOC 不是抓数据",
  459. subtitle: "它是一条从用户声音到经营判断、内容动作和复盘指标的证据链。",
  460. rightTitle: "社区课定位",
  461. rightText: "免费技能用于 OPC / 测试\n付费技能用于企业级稳定交付",
  462. footer: "VOC 理论课",
  463. },
  464. {
  465. type: "agenda",
  466. kicker: "本节学习路线",
  467. title: "先建立判断框架,再进入工具",
  468. subtitle: "学员要先知道为什么采、采什么、采完怎么判断。",
  469. items: [
  470. { title: "为什么", text: "从经验判断转向证据判断", accent: palette.ink },
  471. { title: "看什么", text: "平台声音、评论追问和转化阻力", accent: palette.jade },
  472. { title: "怎么分层", text: "OPC / 测试 / 企业级场景", accent: palette.cinnabar },
  473. { title: "交付什么", text: "问题池、行动计划、报告", accent: palette.gold },
  474. ],
  475. footer: "VOC 理论课",
  476. },
  477. {
  478. type: "claim",
  479. kicker: "课程开场问题",
  480. title: "老板真正缺的不是更多内容,而是可验证的判断",
  481. text: "如果没有用户证据,短视频脚本、产品建议和活动方案都会变成拍脑袋。",
  482. proofTitle: "证据链",
  483. proofText: "用户原话\n→ 问题聚类\n→ 优先级\n→ 动作\n→ 指标复盘",
  484. accent: palette.cinnabar,
  485. footer: "VOC 理论课",
  486. },
  487. {
  488. type: "compare",
  489. kicker: "概念纠偏",
  490. title: "VOC 和普通市场分析的区别",
  491. subtitle: "这页要让学员明白:VOC 的核心不是结论漂亮,而是能追溯。",
  492. columns: [
  493. {
  494. title: "普通分析",
  495. body: "先写观点,再找行业常识支撑。\n容易出现:空泛、像模板、无法落地。",
  496. bottom: "结果:看完觉得对,但不知道明天改什么。",
  497. accent: palette.muted,
  498. fill: palette.mist2,
  499. },
  500. {
  501. title: "VOC 分析",
  502. body: "先抓用户表达,再归纳问题和阻力。\n每个建议都能回到样本、评论、关键词。",
  503. bottom: "结果:能做动作、能验证、能复盘。",
  504. accent: palette.jade,
  505. fill: palette.paper,
  506. },
  507. ],
  508. footer: "VOC 理论课",
  509. },
  510. {
  511. type: "section",
  512. number: "01",
  513. title: "先讲清业务场景",
  514. text: "采集前不先问平台,而是先问:这次要改善哪个经营结果?",
  515. side: "业务问题\n决定数据质量",
  516. footer: "VOC 理论课",
  517. },
  518. {
  519. type: "matrix",
  520. kicker: "采集前置条件",
  521. title: "一条合格 VOC 任务,至少要有 4 个输入",
  522. subtitle: "输入越像业务问题,输出越像经营情报;输入越像泛泛关键词,输出越像闲聊。",
  523. headers: ["输入", "茶饮案例", "为什么重要", "换行业怎么填"],
  524. rows: [
  525. ["业务目标", "提升新客转化", "决定报告回答什么", "咨询转化 / 复购 / 客诉"],
  526. ["决策门槛", "18-22 元价格带", "决定用户比较方式", "客单价 / 合同金额 / 使用成本"],
  527. ["核心问题", "值不值、踩不踩雷", "决定关键词和判断口径", "怕增项 / 怕无效 / 怕售后"],
  528. ["用户原话", "茶底是什么?我要避雷", "防止凭经验发散", "评论、私信、差评、问答"],
  529. ],
  530. footer: "VOC 理论课",
  531. },
  532. {
  533. type: "flow",
  534. kicker: "VOC 小闭环",
  535. title: "不是采完就结束,而是跑完一轮证据闭环",
  536. subtitle: "这页是整门课的主流程,后面的实操课全部围绕它展开。",
  537. steps: [
  538. { title: "定义问题", text: "业务目标 + 用户阻力", accent: palette.ink },
  539. { title: "采集矩阵", text: "平台、人群、关键词", accent: palette.blue },
  540. { title: "真实采集", text: "样本、评论、链接", accent: palette.jade },
  541. { title: "问题池", text: "严重度和优先级", accent: palette.gold },
  542. { title: "单点深挖", text: "动作和验证指标", accent: palette.cinnabar },
  543. { title: "交付复盘", text: "内容、报告、补采", accent: palette.ink2 },
  544. ],
  545. bottom: "每一环都要能回到用户证据,而不是回到模型想象。",
  546. footer: "VOC 理论课",
  547. },
  548. {
  549. type: "compare",
  550. kicker: "技能组合定位",
  551. title: "免费技能和付费技能,不是互相替代",
  552. subtitle: "它们服务于不同阶段:学习验证 vs 企业级稳定交付。",
  553. columns: [
  554. {
  555. title: "免费 / 开源技能",
  556. body: "适合 OPC、教学演示、小范围测试。\n重点是理解流程、验证方法、低成本起步。",
  557. bottom: "判断标准:能跑通,能形成初步洞察。",
  558. accent: palette.blue,
  559. fill: palette.mist2,
  560. },
  561. {
  562. title: "付费 VOC 技能",
  563. body: "适合稳定采集、大批量样本、企业报告。\n重点是权限、余额、错误处理、审计和复用。",
  564. bottom: "判断标准:能稳定交付,能复盘追溯。",
  565. accent: palette.cinnabar,
  566. fill: palette.paper,
  567. },
  568. ],
  569. footer: "VOC 理论课",
  570. },
  571. {
  572. type: "cards",
  573. kicker: "企业级场景为什么需要付费能力",
  574. title: "专业 VOC 交付要解决 6 件免费工具常常忽略的事",
  575. subtitle: "这页用于解释商业边界,不要讲成“免费不好”,而是讲“使用场景不同”。",
  576. cards: [
  577. { title: "稳定采集", text: "接口失败、限流、重试和余额状态可解释", accent: palette.cinnabar },
  578. { title: "批量样本", text: "多关键词、多平台、持续补采", accent: palette.ink },
  579. { title: "证据审计", text: "样本、评论、链接、批次能追溯", accent: palette.jade },
  580. { title: "错误修复", text: "关键词、token、余额和平台异常有明确下一步", accent: palette.gold },
  581. { title: "报告交付", text: "从趋势、问题池到 HTML 报告可复用", accent: palette.blue },
  582. { title: "团队协作", text: "老板、运营、内容、产品都能读懂", accent: palette.cinnabar },
  583. ],
  584. note: "企业级价值不是“多一个工具”,而是减少采集和交付过程中的不确定性。",
  585. footer: "VOC 理论课",
  586. },
  587. {
  588. type: "section",
  589. number: "02",
  590. title: "以茶饮新客转化为统一案例",
  591. text: "所有方法都要落回一个具体业务:18-22 元价格带,如何降低新客首单决策成本。",
  592. side: "值不值\n会不会踩雷\n怎么点最稳",
  593. footer: "VOC 理论课",
  594. },
  595. {
  596. type: "claim",
  597. kicker: "案例洞察",
  598. title: "18-22 元不是绝对贵,真正阻力是看不懂值在哪",
  599. titleSize: 42,
  600. text: "用户会比较套餐、券后价、自制成本、茶底、甜度、配料表和外卖到手体验。",
  601. proofTitle: "用户追问",
  602. proofText: "茶底是什么?\n甜度怎么选?\n买一送一有没有坑?\n外卖到手会翻车吗?",
  603. accent: palette.jade,
  604. footer: "VOC 理论课",
  605. },
  606. {
  607. type: "matrix",
  608. kicker: "平台分工",
  609. title: "抖音和小红书看到的用户心态不同",
  610. subtitle: "同一个业务问题,需要用不同平台补齐决策链路。",
  611. headers: ["平台", "更像什么", "重点看什么", "转成什么动作"],
  612. rows: [
  613. ["小红书", "做功课和避坑", "值不值、茶底、甜度、配料", "解释型笔记、避坑指南"],
  614. ["抖音", "触发注意和下单", "低价新品、团购、买一送一", "短视频钩子、活动规则"],
  615. ["评论区", "转化前验真", "怎么点、多少钱、会不会踩雷", "置顶回复、统一话术"],
  616. ["门店/外卖", "体验兑现", "出杯、排队、到手口感", "流程提示、外卖稳定款"],
  617. ],
  618. footer: "VOC 理论课",
  619. },
  620. {
  621. type: "cards",
  622. kicker: "报告不等于策略口号",
  623. title: "一份合格 VOC 报告至少包含 6 类内容",
  624. subtitle: "后面的实战课会把每一类都跑出来。",
  625. cards: [
  626. { title: "数据口径", text: "平台、关键词、样本数、评论数", accent: palette.ink },
  627. { title: "显著问题", text: "用户反复表达的阻力", accent: palette.cinnabar },
  628. { title: "证据样本", text: "原话、链接、互动和截图", accent: palette.jade },
  629. { title: "优先级", text: "按业务影响排序,不只看词频", accent: palette.gold },
  630. { title: "行动计划", text: "今天、7 天、长期沉淀", accent: palette.blue },
  631. { title: "验证指标", text: "评论变化、咨询、下单、复购", accent: palette.cinnabar },
  632. ],
  633. footer: "VOC 理论课",
  634. },
  635. {
  636. type: "claim",
  637. kicker: "学员最终要带走的能力",
  638. title: "看到一条评论,就能判断它该进入哪个经营环节",
  639. text: "不是把评论复制给 AI,而是识别它影响产品、价格、内容、服务还是信任。",
  640. proofTitle: "判断题",
  641. proofText: "“我要避雷”\n不是情绪词\n而是首单风险信号",
  642. accent: palette.cinnabar,
  643. dark: true,
  644. footer: "VOC 理论课",
  645. },
  646. {
  647. type: "checklist",
  648. kicker: "理论课收尾",
  649. title: "进入实战课前,学员需要记住 5 个标准",
  650. subtitle: "这页可以作为第一节课的课后检查。",
  651. items: [
  652. ["先业务", "先写经营目标,再写关键词", "避免乱采"],
  653. ["先 sample", "先跑通格式,再 live 采集", "降低卡点"],
  654. ["有证据", "判断必须能回到原话或样本", "防止空泛"],
  655. ["分场景", "免费用于测试,付费用于稳定交付", "边界清楚"],
  656. ["可验证", "每个建议都要有 7 天指标", "能复盘"],
  657. ],
  658. footer: "VOC 理论课",
  659. },
  660. {
  661. type: "section",
  662. number: "NEXT",
  663. title: "下一节:从 FmodeStudio 开始实操",
  664. text: "创建项目模板,安装技能,跑 sample,进入 live,生成问题池、脚本和 HTML 报告。",
  665. side: "打开工具\n开始交付",
  666. footer: "VOC 理论课",
  667. },
  668. ];
  669. const practicalSlides = [
  670. {
  671. type: "cover",
  672. kicker: "实战课 02",
  673. title: "FmodeStudio VOC 实操",
  674. subtitle: "从技能安装、项目模板创建,到小红书/抖音采集和 HTML 报告交付。",
  675. rightTitle: "两段演示",
  676. rightText: "免费技能安装与演示\n付费 VOC 技能企业级展示",
  677. accent: palette.jade,
  678. footer: "VOC 实战课",
  679. },
  680. {
  681. type: "agenda",
  682. kicker: "本节产出",
  683. title: "学员跟完后,要拿到一套能复用的项目样板",
  684. subtitle: "不是只看老师演示,而是知道自己下一次怎么换行业复跑。",
  685. items: [
  686. { title: "建项目", text: "FmodeStudio + 项目模板", accent: palette.ink },
  687. { title: "装技能", text: "免费技能与付费技能两条线", accent: palette.blue },
  688. { title: "跑采集", text: "sample、矩阵、live 小规模", accent: palette.jade },
  689. { title: "交付", text: "问题池、内容计划、HTML 报告", accent: palette.cinnabar },
  690. ],
  691. footer: "VOC 实战课",
  692. },
  693. {
  694. type: "flow",
  695. kicker: "完整演示路径",
  696. title: "实操课按 8 个动作推进",
  697. subtitle: "每一步都有截图点和通过标准,方便课程发布后学员独立复盘。",
  698. steps: [
  699. { title: "打开 Studio", text: "创建课程项目" },
  700. { title: "选模板", text: "VOC 项目结构" },
  701. { title: "装免费技能", text: "跑通 OPC/测试" },
  702. { title: "装付费技能", text: "workspace smoke" },
  703. { title: "sample", text: "先看格式" },
  704. { title: "live", text: "小规模真实采集" },
  705. ],
  706. bottom: "先证明流程可用,再追求样本规模;先小范围验证,再进入企业级采集。",
  707. footer: "VOC 实战课",
  708. },
  709. {
  710. type: "checklist",
  711. kicker: "课前准备",
  712. title: "开始前先检查这 5 项",
  713. subtitle: "这页是实操课开场,不要等出错了再补环境。",
  714. items: [
  715. ["FmodeStudio", "能打开并创建项目", "通过"],
  716. ["Claude Code", "能在项目内正常对话", "通过"],
  717. ["Node / npx", "终端能返回版本号", "通过"],
  718. ["截图文件夹", "为每个步骤留空截图", "通过"],
  719. [".env.local", "如有 token,不展示真实值", "通过"],
  720. ],
  721. footer: "VOC 实战课",
  722. },
  723. {
  724. type: "matrix",
  725. kicker: "项目模板",
  726. title: "VOC 项目模板建议包含这些文件夹",
  727. subtitle: "FmodeStudio 里创建项目后,学员能用这个结构沉淀材料。",
  728. headers: ["目录", "放什么", "为什么需要", "截图点"],
  729. rows: [
  730. ["01-inputs", "业务背景、关键词、用户原话", "防止忘记输入口径", "项目初始化"],
  731. ["02-samples", "sample 输出和第一轮观察", "先跑通流程", "sample 结果"],
  732. ["03-live-data", "live 样本、评论、链接", "保留真实证据", "工具输出"],
  733. ["04-analysis", "矩阵、问题池、深挖", "形成经营判断", "问题排序"],
  734. ["05-delivery", "脚本、HTML、复盘指标", "交付给老板/团队", "最终报告"],
  735. ],
  736. footer: "VOC 实战课",
  737. },
  738. {
  739. type: "section",
  740. number: "A",
  741. title: "免费技能安装与演示",
  742. text: "免费技能用于让学员理解技能调用、OPC 和小范围验证,不承诺企业级稳定采集。",
  743. side: "学习\n测试\n验证",
  744. footer: "VOC 实战课",
  745. },
  746. {
  747. type: "cards",
  748. kicker: "免费技能演示口径",
  749. title: "免费技能这一段要演示 4 件事",
  750. subtitle: "这里不要过度承诺,只让学员看懂技能如何进入业务流程。",
  751. cards: [
  752. { title: "找到技能", text: "在 FmodeStudio / Claude Code 中确认技能入口", accent: palette.blue },
  753. { title: "安装或启用", text: "按该免费技能的 README 操作", accent: palette.jade },
  754. { title: "跑 sample", text: "先用课程案例或内置样例输出", accent: palette.gold },
  755. { title: "读结果", text: "看是否有证据、边界和下一步", accent: palette.cinnabar },
  756. ],
  757. note: "如果免费技能名单暂未确定,这一页保留为“替换实际开源技能名称和链接”的通用演示页。",
  758. footer: "VOC 实战课",
  759. },
  760. {
  761. type: "prompt",
  762. kicker: "免费技能演示 Prompt",
  763. title: "先让 Claude Code 明确使用当前免费技能",
  764. subtitle: "这段用于 OPC/测试,不进入大批量 live 采集。",
  765. label: "复制给 Claude Code",
  766. code: `请使用当前项目中已安装的免费 / 开源 VOC 技能,先不要做大批量真实采集。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:值不值、会不会踩雷、首单怎么点最稳。\n\n请先用 sample 或小范围测试数据输出:\n1. 第一轮初步判断\n2. 证据样本\n3. 用户顾虑\n4. 数据边界\n5. 下一步需要真实采集验证的问题`,
  767. size: 16,
  768. footer: "VOC 实战课",
  769. },
  770. {
  771. type: "compare",
  772. kicker: "免费技能的边界",
  773. title: "演示结束后,要把边界讲清楚",
  774. subtitle: "让学员知道什么时候免费技能够用,什么时候需要企业级能力。",
  775. columns: [
  776. {
  777. title: "够用的场景",
  778. body: "学习 VOC 方法\nOPC / 概念验证\n小样本跑流程\n检查 prompt 是否合理",
  779. bottom: "目标:理解流程和判断口径",
  780. accent: palette.jade,
  781. fill: palette.mist2,
  782. },
  783. {
  784. title: "不建议硬撑的场景",
  785. body: "客户交付\n多平台批量采集\n长期复盘\n需要审计和错误处理",
  786. bottom: "目标:稳定、可追溯、可复用",
  787. accent: palette.cinnabar,
  788. fill: palette.paper,
  789. },
  790. ],
  791. footer: "VOC 实战课",
  792. },
  793. {
  794. type: "section",
  795. number: "B",
  796. title: "付费 VOC 技能企业级展示",
  797. text: "这一段演示正式安装、token、sample、live 和错误处理,让企业客户看到稳定交付链路。",
  798. side: "稳定\n批量\n审计",
  799. footer: "VOC 实战课",
  800. },
  801. {
  802. type: "prompt",
  803. kicker: "付费技能安装",
  804. title: "在项目工作区安装 VOC 技能包",
  805. subtitle: "这一页要让学员知道:安装发生在当前项目,不是口头说“我有技能”。",
  806. label: "PowerShell",
  807. code: `npx --yes @vocmarket/voc-skill@latest workspace --smoke\n\n# 安装后检查\n# .\\.mcp.json\n# .\\.claude\\plugins\\voc-intelligence\n# .\\.claude\\skills\\xiaohongshu-trend-intelligence\n# .\\.claude\\skills\\douyin-trend-intelligence`,
  808. footer: "VOC 实战课",
  809. },
  810. {
  811. type: "checklist",
  812. kicker: "安装通过标准",
  813. title: "看到这些结果,才算安装成功",
  814. subtitle: "不要只看命令跑完,要看项目里是否写入了技能和 MCP 配置。",
  815. items: [
  816. ["smoke 通过", "终端没有报错,提示 ready", "截图"],
  817. [".mcp.json", "工作区根目录生成 MCP 配置", "截图"],
  818. ["plugins", "出现 voc-intelligence 插件目录", "截图"],
  819. ["skills", "出现小红书和抖音技能说明", "截图"],
  820. ["Claude Code", "重启后能触发 VOC 技能", "截图"],
  821. ],
  822. footer: "VOC 实战课",
  823. },
  824. {
  825. type: "prompt",
  826. kicker: "token 配置",
  827. title: "live 采集前,先配置 token,但不要展示真实值",
  828. subtitle: "课程截图里可以展示文件名,不展示 token 内容。",
  829. label: ".env.local",
  830. code: `VOC_TOKEN=你的服务 token\n\n# 注意:\n# 1. 不要提交到 Git\n# 2. 不要放进截图\n# 3. 不要发到聊天记录\n# 4. token 异常时先做 token / 余额预检`,
  831. footer: "VOC 实战课",
  832. },
  833. {
  834. type: "prompt",
  835. kicker: "sample 首跑",
  836. title: "先用 sample 跑通输出格式",
  837. subtitle: "这一步的目标不是拿真实结论,而是确认技能调用、结构和报告阅读方式。",
  838. label: "复制给 Claude Code",
  839. code: `请使用已安装的 VOC 技能,不要凭经验回答。\n\n帮我做一份茶饮新客转化的小红书趋势情报。\n先用 sample 模式跑通流程,不要真实采集。\n业务目标:提升新客转化。\n价格带:18-22 元。\n重点看:值不值、会不会踩雷、首单怎么点最稳。\n\n请输出第一轮初步判断、样本观察、用户顾虑、证据样本和待确认问题。`,
  840. size: 17,
  841. footer: "VOC 实战课",
  842. },
  843. {
  844. type: "matrix",
  845. kicker: "采集矩阵",
  846. title: "进入 live 前,先写采集矩阵",
  847. subtitle: "矩阵让采集从“乱搜关键词”变成“围绕业务问题取证”。",
  848. headers: ["字段", "本案例填写", "合格标准", "截图点"],
  849. rows: [
  850. ["平台", "小红书 / 抖音", "平台分开,不混写", "矩阵表头"],
  851. ["目标人群", "茶饮新客、价格敏感用户", "必须对应转化目标", "人群列"],
  852. ["关键词", "券后价、首单、避坑、茶底", "像用户会搜索的话", "关键词列"],
  853. ["样本量", "小规模先跑", "不要第一轮全量", "样本量列"],
  854. ["判断标准", "是否影响首单转化", "能判断是否深挖", "标准列"],
  855. ],
  856. footer: "VOC 实战课",
  857. },
  858. {
  859. type: "prompt",
  860. kicker: "live 小规模采集",
  861. title: "真实采集先小规模,不要一上来全量",
  862. subtitle: "这段是正式演示付费技能价值:平台、关键词、样本数、评论数和错误处理。",
  863. label: "复制给 Claude Code",
  864. code: `请进入真实采集 live 模式。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n平台:小红书、抖音。\n采集范围:先小规模采集,不要全量。\n\n重点关键词:\n小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度\n抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑\n\n请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。`,
  865. size: 15,
  866. footer: "VOC 实战课",
  867. },
  868. {
  869. type: "cards",
  870. kicker: "采集结果怎么读",
  871. title: "live 结果出来后,不要立刻写最终策略",
  872. subtitle: "先判断数据质量,再决定是否补采。",
  873. cards: [
  874. { title: "样本数", text: "看笔记/视频数量是否足够", accent: palette.ink },
  875. { title: "评论数", text: "看是否有真实追问", accent: palette.jade },
  876. { title: "显著问题", text: "看是否集中到转化阻力", accent: palette.cinnabar },
  877. { title: "证据样本", text: "看能否回到原帖/原视频", accent: palette.gold },
  878. { title: "补采词", text: "看下一轮该补什么", accent: palette.blue },
  879. { title: "边界", text: "低样本只写阶段性信号", accent: palette.cinnabar },
  880. ],
  881. footer: "VOC 实战课",
  882. },
  883. {
  884. type: "flow",
  885. kicker: "从采集到交付",
  886. title: "后半段把结果转成 4 个交付物",
  887. subtitle: "学员看到这里要知道:采集只是开始,交付才是课程成果。",
  888. steps: [
  889. { title: "问题池", text: "Top 3 转化阻力" },
  890. { title: "单点深挖", text: "价格/性价比" },
  891. { title: "内容计划", text: "7 天短视频脚本" },
  892. { title: "HTML 报告", text: "老板/运营可读" },
  893. ],
  894. bottom: "每个交付物都要有:证据、动作、指标。",
  895. footer: "VOC 实战课",
  896. },
  897. {
  898. type: "prompt",
  899. kicker: "问题池 Prompt",
  900. title: "把采集结果整理成 VOC 问题池",
  901. subtitle: "问题池要按业务影响排序,不要只按词频排序。",
  902. label: "复制给 Claude Code",
  903. code: `请把上面的采集结果整理成 VOC 问题池。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n重点影响:新客转化、客单结构、评论信任。\n\n要求:\n1. 按业务影响排序,不要只按词频排序\n2. 每个问题都要有用户证据\n3. 标注影响环节\n4. 给出建议动作\n5. 输出 Top 3 问题和下一步是否适合深挖`,
  904. size: 17,
  905. footer: "VOC 实战课",
  906. },
  907. {
  908. type: "prompt",
  909. kicker: "单点深挖 Prompt",
  910. title: "选择 Top 1:价格/性价比",
  911. subtitle: "不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。",
  912. label: "复制给 Claude Code",
  913. code: `请继续深挖这个 VOC 问题:价格/性价比。\n\n业务背景:茶饮连锁,产品定价在 18-22 元区间。\n主要目标:提升新客转化。\n\n证据:\n1. 探店内容别只拍氛围,评论区最关心值不值\n2. 茶底是什么?我要避雷\n3. 低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但仍需解释规则、口味和是否有坑。\n\n请输出表层问题、深层问题、今天动作、7 天验证动作、评论区回复方向和验证指标。`,
  914. size: 15,
  915. footer: "VOC 实战课",
  916. },
  917. {
  918. type: "prompt",
  919. kicker: "内容计划 Prompt",
  920. title: "把 VOC 问题变成 7 天短视频计划",
  921. subtitle: "每条内容都必须绑定真实用户问题,不写泛泛爆款标题。",
  922. label: "复制给 Claude Code",
  923. code: `请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。\n\n每条内容包含:对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。\n\n方向覆盖:第一次来怎么点;18-22 元值在哪;券后价和套餐;茶底甜度配料;避坑款;外卖到手;团购规则。`,
  924. size: 15,
  925. footer: "VOC 实战课",
  926. },
  927. {
  928. type: "prompt",
  929. kicker: "HTML 报告 Prompt",
  930. title: "HTML 报告让 Claude Code 直接生成文件",
  931. subtitle: "不要让它进入复杂 workflow;用前面材料直接生成可打开页面。",
  932. label: "复制给 Claude Code",
  933. code: `请基于前面 VOC 分析材料,直接生成一份老板/运营视角可阅读的 HTML 策略报告。\n\n要求:\n1. 使用基础样本数据、采集矩阵、问题池、单点深挖和内容计划\n2. 生成一个可打开的 HTML 文件\n3. 风格参考青花瓷:白底、瓷蓝、细线纹样、清爽高级\n4. 包含关键结论、采集矩阵、问题池、单点深挖、7 天内容计划、补采关键词\n5. 生成后请打开浏览器预览,并根据页面效果继续优化`,
  934. size: 16,
  935. footer: "VOC 实战课",
  936. },
  937. {
  938. type: "checklist",
  939. kicker: "常见报错",
  940. title: "实操中遇到错误,按状态判断下一步",
  941. subtitle: "这页要让学员不再被“余额不足/关键词错误/token 缺失”卡死。",
  942. items: [
  943. ["needs_token", "没有检测到 token", "配置 .env.local"],
  944. ["needs_recharge", "未开通或余额不足", "打开充值/开通链接"],
  945. ["关键词错误", "搜索接口不接受入参", "缩短关键词、加空格重试"],
  946. ["链接 404", "平台链接或原帖失效", "保留样本编号和截图"],
  947. ["工具不触发", "Claude Code 没调用技能", "明确要求使用 VOC 技能"],
  948. ],
  949. footer: "VOC 实战课",
  950. },
  951. {
  952. type: "compare",
  953. kicker: "课程商业表达",
  954. title: "免费技能满足学习需求,付费技能满足企业稳定需求",
  955. subtitle: "这页可以作为社区课转化页,但语气要克制。",
  956. columns: [
  957. {
  958. title: "买课用户",
  959. body: "能用免费技能或 sample 跑通 VOC 方法。\n重点是理解流程、学会判断、能做小范围分析。",
  960. bottom: "课程价值:学会做一次 VOC 小闭环。",
  961. accent: palette.jade,
  962. fill: palette.mist2,
  963. },
  964. {
  965. title: "企业用户",
  966. body: "需要稳定采集、批量样本、权限管理、错误处理、审计和报告复用。",
  967. bottom: "技能价值:稳定地把 VOC 变成交付。",
  968. accent: palette.cinnabar,
  969. fill: palette.paper,
  970. },
  971. ],
  972. footer: "VOC 实战课",
  973. },
  974. {
  975. type: "checklist",
  976. kicker: "实战课交付检查",
  977. title: "学员最终提交 6 个成果",
  978. subtitle: "这页作为实战课收尾,明确学完以后手上应该有什么。",
  979. items: [
  980. ["项目模板", "有 inputs / samples / live-data / analysis / delivery", "可复用"],
  981. ["技能可用", "免费技能演示 + 付费技能 smoke", "可截图"],
  982. ["采集矩阵", "平台、人群、关键词、样本量、标准", "可复跑"],
  983. ["问题池", "Top 3 问题和证据", "可决策"],
  984. ["内容计划", "7 天标题、口播、指标", "可拍摄"],
  985. ["HTML 报告", "老板/运营可直接阅读", "可交付"],
  986. ],
  987. footer: "VOC 实战课",
  988. },
  989. {
  990. type: "section",
  991. number: "END",
  992. title: "下一次换行业,只替换输入,不替换流程",
  993. text: "业务目标、价格门槛、用户原话、关键词、平台可以换;证据链和交付结构不要换。",
  994. side: "可复制\n可复盘\n可交付",
  995. footer: "VOC 实战课",
  996. },
  997. ];
  998. const renderers = { cover, agenda, claim, compare, flow, matrix, prompt, checklist, cards, section };
  999. async function saveBlobToFile(blob, filePath) {
  1000. await fs.mkdir(path.dirname(filePath), { recursive: true });
  1001. await fs.writeFile(filePath, Buffer.from(await blob.arrayBuffer()));
  1002. }
  1003. async function writeDeck({ fileName, slides }) {
  1004. const artifact = await import(pathToFileURL(ARTIFACT_TOOL).href);
  1005. const { Presentation, PresentationFile } = artifact;
  1006. const presentation = Presentation.create({ slideSize: { width: W, height: H } });
  1007. slides.forEach((spec, idx) => {
  1008. renderers[spec.type](presentation, spec, { index: idx + 1, total: slides.length });
  1009. });
  1010. const base = fileName.replace(/\.pptx$/i, "");
  1011. const deckPreviewDir = path.join(PREVIEW_DIR, base);
  1012. const deckLayoutDir = path.join(LAYOUT_DIR, base);
  1013. const deckQaDir = path.join(QA_DIR, base);
  1014. await fs.mkdir(deckPreviewDir, { recursive: true });
  1015. await fs.mkdir(deckLayoutDir, { recursive: true });
  1016. await fs.mkdir(deckQaDir, { recursive: true });
  1017. const previewPaths = [];
  1018. for (let i = 0; i < presentation.slides.count; i += 1) {
  1019. const slide = presentation.slides.getItem(i);
  1020. const previewPath = path.join(deckPreviewDir, `slide-${String(i + 1).padStart(2, "0")}.png`);
  1021. const png = await presentation.export({ slide, format: "png", scale: 1 });
  1022. await saveBlobToFile(png, previewPath);
  1023. previewPaths.push(previewPath);
  1024. const layout = await presentation.export({ slide, format: "layout" });
  1025. await fs.writeFile(
  1026. path.join(deckLayoutDir, `slide-${String(i + 1).padStart(2, "0")}.layout.json`),
  1027. await layout.text(),
  1028. "utf8",
  1029. );
  1030. }
  1031. const pptx = await PresentationFile.exportPptx(presentation);
  1032. const outPath = path.join(OUT_DIR, fileName);
  1033. await fs.mkdir(OUT_DIR, { recursive: true });
  1034. await pptx.save(outPath);
  1035. const contactSheet = path.join(deckQaDir, "contact-sheet.png");
  1036. const cs = spawnSync(PYTHON, [MAKE_CONTACT_SHEET, "--output", contactSheet, ...previewPaths], {
  1037. encoding: "utf8",
  1038. });
  1039. if (cs.status !== 0) {
  1040. throw new Error(`contact sheet failed\n${cs.stdout}\n${cs.stderr}`);
  1041. }
  1042. return { outPath, previewDir: deckPreviewDir, layoutDir: deckLayoutDir, contactSheet, slides: slides.length };
  1043. }
  1044. async function main() {
  1045. await fs.mkdir(WORKSPACE, { recursive: true });
  1046. const theory = await writeDeck({
  1047. fileName: "VOC理论课-从用户声音到经营证据链.pptx",
  1048. slides: theorySlides,
  1049. });
  1050. const practical = await writeDeck({
  1051. fileName: "VOC实战课-FmodeStudio技能项目模板.pptx",
  1052. slides: practicalSlides,
  1053. });
  1054. const manifest = { generatedAt: new Date().toISOString(), workspace: WORKSPACE, decks: [theory, practical] };
  1055. await fs.writeFile(path.join(WORKSPACE, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
  1056. console.log(JSON.stringify(manifest, null, 2));
  1057. }
  1058. main().catch((error) => {
  1059. console.error(error.stack || error.message || String(error));
  1060. process.exit(1);
  1061. });