| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094 |
- import fs from "node:fs/promises";
- import path from "node:path";
- import { spawnSync } from "node:child_process";
- import { pathToFileURL } from "node:url";
- const ROOT = path.resolve("E:/workspace/openclaw-voc-skill");
- const OUT_DIR = path.join(ROOT, "docs", "voc-skill-student-course");
- const WORKSPACE = path.join(
- ROOT,
- "outputs",
- process.env.CODEX_THREAD_ID || `manual-${new Date().toISOString().slice(0, 10).replaceAll("-", "")}`,
- "presentations",
- "voc-community-course-two-lessons",
- );
- const PREVIEW_DIR = path.join(WORKSPACE, "preview");
- const LAYOUT_DIR = path.join(WORKSPACE, "layout");
- const QA_DIR = path.join(WORKSPACE, "qa");
- const NODE_HOME = process.env.USERPROFILE || process.env.HOME;
- const ARTIFACT_TOOL = path.join(
- NODE_HOME,
- ".cache",
- "codex-runtimes",
- "codex-primary-runtime",
- "dependencies",
- "node",
- "node_modules",
- "@oai",
- "artifact-tool",
- "dist",
- "artifact_tool.mjs",
- );
- const PYTHON =
- process.env.PYTHON ||
- path.join(
- NODE_HOME,
- ".cache",
- "codex-runtimes",
- "codex-primary-runtime",
- "dependencies",
- "python",
- "python.exe",
- );
- const MAKE_CONTACT_SHEET = path.join(
- NODE_HOME,
- ".codex",
- "plugins",
- "cache",
- "openai-primary-runtime",
- "presentations",
- "26.601.10930",
- "skills",
- "presentations",
- "scripts",
- "make_contact_sheet.py",
- );
- const W = 1280;
- const H = 720;
- const palette = {
- porcelain: "#F8FBFF",
- paper: "#FFFFFF",
- ink: "#133B68",
- ink2: "#0A2440",
- blue: "#1E68A8",
- mist: "#E8F2FC",
- mist2: "#F1F7FC",
- jade: "#1F7A72",
- jadeSoft: "#DDF2EE",
- cinnabar: "#C9513B",
- cinnabarSoft: "#F8E5DE",
- gold: "#B88932",
- charcoal: "#13202B",
- muted: "#5F7082",
- line: "#C7D9EA",
- dark: "#0F1A24",
- };
- const FONT = {
- title: "Microsoft YaHei UI",
- body: "Microsoft YaHei",
- mono: "Cascadia Mono",
- };
- function line(fill = "#00000000", width = 0, style = "solid") {
- return { style, fill, width };
- }
- function addShape(slide, options) {
- const {
- geometry = "rect",
- fill = "#00000000",
- border = "#00000000",
- borderWidth = 0,
- left,
- top,
- width,
- height,
- name,
- } = options;
- return slide.shapes.add({
- geometry,
- name,
- position: { left, top, width, height },
- fill,
- line: line(border, borderWidth),
- });
- }
- function addText(slide, options) {
- const {
- text,
- left,
- top,
- width,
- height,
- size = 24,
- color = palette.charcoal,
- bold = false,
- typeface = FONT.body,
- align = "left",
- valign = "top",
- fill = "#00000000",
- border = "#00000000",
- borderWidth = 0,
- insets = { left: 0, right: 0, top: 0, bottom: 0 },
- } = options;
- const shape = addShape(slide, {
- left,
- top,
- width,
- height,
- fill,
- border,
- borderWidth,
- });
- shape.text = text;
- shape.text.fontSize = size;
- shape.text.color = color;
- shape.text.bold = bold;
- shape.text.typeface = typeface;
- shape.text.alignment = align;
- shape.text.verticalAlignment = valign;
- shape.text.insets = insets;
- return shape;
- }
- function addFooter(slide, deckLabel, index, total) {
- addText(slide, {
- text: deckLabel,
- left: 64,
- top: 668,
- width: 600,
- height: 22,
- size: 13,
- color: palette.muted,
- });
- addText(slide, {
- text: `${String(index).padStart(2, "0")} / ${String(total).padStart(2, "0")}`,
- left: 1112,
- top: 666,
- width: 100,
- height: 24,
- size: 14,
- color: palette.muted,
- typeface: FONT.mono,
- align: "right",
- });
- }
- function addTitle(slide, title, kicker, subtitle) {
- if (kicker) {
- addText(slide, {
- text: kicker,
- left: 64,
- top: 46,
- width: 680,
- height: 28,
- size: 16,
- bold: true,
- color: palette.jade,
- });
- }
- addText(slide, {
- text: title,
- left: 64,
- top: kicker ? 82 : 58,
- width: 860,
- height: 88,
- size: 38,
- bold: true,
- color: palette.ink2,
- typeface: FONT.title,
- });
- if (subtitle) {
- addText(slide, {
- text: subtitle,
- left: 66,
- top: 174,
- width: 780,
- height: 44,
- size: 18,
- color: palette.muted,
- });
- }
- }
- function bg(slide, tone = "light") {
- addShape(slide, { left: 0, top: 0, width: W, height: H, fill: tone === "dark" ? palette.ink2 : palette.porcelain });
- if (tone !== "dark") {
- addShape(slide, { left: 0, top: 0, width: 24, height: H, fill: palette.ink });
- addShape(slide, { left: 24, top: 0, width: 6, height: H, fill: palette.jade });
- }
- }
- function cover(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide, "light");
- addShape(slide, { left: 760, top: 0, width: 520, height: H, fill: spec.accent || palette.ink });
- addShape(slide, { left: 820, top: 78, width: 330, height: 330, fill: "#FFFFFF12", border: "#FFFFFF22", borderWidth: 1 });
- addShape(slide, { left: 880, top: 138, width: 210, height: 210, fill: "#FFFFFF12", border: "#FFFFFF2A", borderWidth: 1 });
- addText(slide, {
- text: spec.kicker,
- left: 70,
- top: 78,
- width: 560,
- height: 30,
- size: 17,
- bold: true,
- color: palette.jade,
- });
- addText(slide, {
- text: spec.title,
- left: 68,
- top: 128,
- width: 610,
- height: 170,
- size: 54,
- bold: true,
- color: palette.ink2,
- typeface: FONT.title,
- });
- addText(slide, {
- text: spec.subtitle,
- left: 72,
- top: 330,
- width: 585,
- height: 72,
- size: 23,
- color: palette.charcoal,
- });
- addText(slide, {
- text: spec.rightTitle,
- left: 820,
- top: 468,
- width: 330,
- height: 64,
- size: 32,
- bold: true,
- color: palette.paper,
- typeface: FONT.title,
- });
- addText(slide, {
- text: spec.rightText,
- left: 822,
- top: 545,
- width: 330,
- height: 84,
- size: 18,
- color: "#D8EAF8",
- });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function agenda(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const y = 260;
- const gap = 26;
- const w = (1088 - gap * (spec.items.length - 1)) / spec.items.length;
- spec.items.forEach((item, i) => {
- const x = 76 + i * (w + gap);
- addShape(slide, { left: x, top: y, width: w, height: 245, fill: item.fill || palette.paper, border: palette.line, borderWidth: 1 });
- addText(slide, {
- text: `0${i + 1}`,
- left: x + 24,
- top: y + 22,
- width: 90,
- height: 58,
- size: 48,
- bold: true,
- color: item.accent || palette.blue,
- typeface: FONT.mono,
- });
- addText(slide, {
- text: item.title,
- left: x + 24,
- top: y + 96,
- width: w - 48,
- height: 54,
- size: 25,
- bold: true,
- color: palette.ink2,
- typeface: FONT.title,
- });
- addText(slide, {
- text: item.text,
- left: x + 24,
- top: y + 164,
- width: w - 48,
- height: 58,
- size: 17,
- color: palette.muted,
- });
- });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function claim(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide, spec.dark ? "dark" : "light");
- const c = spec.dark ? palette.paper : palette.ink2;
- const muted = spec.dark ? "#C8D9E8" : palette.muted;
- addText(slide, { text: spec.kicker || "", left: 72, top: 56, width: 460, height: 28, size: 16, bold: true, color: spec.dark ? palette.jadeSoft : palette.jade });
- addText(slide, { text: spec.title, left: 70, top: 96, width: 740, height: 150, size: spec.titleSize || 46, bold: true, color: c, typeface: FONT.title });
- addText(slide, { text: spec.text, left: 74, top: 252, width: 600, height: 88, size: 22, color: muted });
- 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 });
- addText(slide, { text: spec.proofTitle, left: 812, top: 158, width: 276, height: 50, size: 27, bold: true, color: c, typeface: FONT.title, align: "center" });
- addText(slide, { text: spec.proofText, left: 820, top: 232, width: 260, height: 164, size: 20, color: muted, align: "center", valign: "mid" });
- addShape(slide, { left: 812, top: 426, width: 276, height: 8, fill: spec.accent || palette.cinnabar });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function compare(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const colW = spec.columns.length === 2 ? 510 : 340;
- const startX = spec.columns.length === 2 ? 108 : 86;
- const gap = spec.columns.length === 2 ? 42 : 28;
- spec.columns.forEach((col, i) => {
- const x = startX + i * (colW + gap);
- addShape(slide, { left: x, top: 232, width: colW, height: 348, fill: col.fill || palette.paper, border: col.border || palette.line, borderWidth: 1 });
- addShape(slide, { left: x, top: 232, width: colW, height: 12, fill: col.accent || palette.blue });
- 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 });
- addText(slide, { text: col.body, left: x + 30, top: 340, width: colW - 60, height: 155, size: 19, color: palette.charcoal });
- addText(slide, { text: col.bottom, left: x + 30, top: 518, width: colW - 60, height: 36, size: 17, color: palette.muted });
- });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function flow(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const n = spec.steps.length;
- const startX = 78;
- const y = 278;
- const boxW = (1090 - (n - 1) * 18) / n;
- spec.steps.forEach((step, i) => {
- const x = startX + i * (boxW + 18);
- addShape(slide, { left: x, top: y, width: boxW, height: 190, fill: step.fill || palette.paper, border: palette.line, borderWidth: 1 });
- 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 });
- addText(slide, { text: step.title, left: x + 18, top: y + 64, width: boxW - 36, height: 56, size: 21, bold: true, color: palette.ink2 });
- addText(slide, { text: step.text, left: x + 18, top: y + 126, width: boxW - 36, height: 40, size: 14, color: palette.muted });
- if (i < n - 1) {
- addText(slide, { text: "→", left: x + boxW + 2, top: y + 70, width: 18, height: 28, size: 24, bold: true, color: palette.line, align: "center" });
- }
- });
- if (spec.bottom) {
- addText(slide, { text: spec.bottom, left: 100, top: 530, width: 1040, height: 40, size: 20, bold: true, color: palette.cinnabar, align: "center" });
- }
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function matrix(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const x = 76;
- const y = 225;
- const w = 1120;
- const rowH = 66;
- const colW = [210, 290, 310, 310];
- addShape(slide, { left: x, top: y, width: w, height: rowH, fill: palette.ink, border: palette.ink, borderWidth: 1 });
- spec.headers.forEach((h, i) => {
- const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
- addText(slide, { text: h, left: left + 16, top: y + 18, width: colW[i] - 32, height: 28, size: 17, bold: true, color: palette.paper });
- });
- spec.rows.forEach((row, r) => {
- const top = y + rowH * (r + 1);
- addShape(slide, { left: x, top, width: w, height: rowH, fill: r % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
- row.forEach((cell, i) => {
- const left = x + colW.slice(0, i).reduce((a, b) => a + b, 0);
- 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 });
- });
- });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function prompt(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- addShape(slide, { left: 82, top: 230, width: 1110, height: 350, fill: palette.dark, border: "#27394A", borderWidth: 1 });
- addShape(slide, { left: 82, top: 230, width: 1110, height: 50, fill: "#162A3D" });
- addShape(slide, { left: 106, top: 246, width: 12, height: 12, fill: palette.cinnabar });
- addShape(slide, { left: 128, top: 246, width: 12, height: 12, fill: palette.gold });
- addShape(slide, { left: 150, top: 246, width: 12, height: 12, fill: palette.jade });
- addText(slide, { text: spec.label || "Prompt / Command", left: 184, top: 241, width: 400, height: 22, size: 14, color: "#AFC6D8", typeface: FONT.mono });
- addText(slide, { text: spec.code, left: 112, top: 298, width: 1028, height: 248, size: spec.size || 18, color: "#ECF5FF", typeface: FONT.mono });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function checklist(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const x = 92;
- let y = 230;
- spec.items.forEach((item, i) => {
- addShape(slide, { left: x, top: y, width: 1040, height: 58, fill: i % 2 ? palette.paper : palette.mist2, border: palette.line, borderWidth: 1 });
- addText(slide, { text: item[0], left: x + 22, top: y + 15, width: 215, height: 26, size: 18, bold: true, color: palette.ink2 });
- addText(slide, { text: item[1], left: x + 265, top: y + 15, width: 420, height: 28, size: 17, color: palette.charcoal });
- addText(slide, { text: item[2], left: x + 720, top: y + 15, width: 290, height: 28, size: 16, color: palette.jade, bold: true });
- y += 66;
- });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function cards(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide);
- addTitle(slide, spec.title, spec.kicker, spec.subtitle);
- const cols = spec.cards.length === 4 ? 4 : 3;
- const gap = 24;
- const cardW = (1094 - gap * (cols - 1)) / cols;
- spec.cards.forEach((card, i) => {
- const row = Math.floor(i / cols);
- const col = i % cols;
- const x = 76 + col * (cardW + gap);
- const y = 230 + row * 168;
- addShape(slide, { left: x, top: y, width: cardW, height: 142, fill: card.fill || palette.paper, border: palette.line, borderWidth: 1 });
- 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 });
- addText(slide, { text: card.text, left: x + 24, top: y + 68, width: cardW - 48, height: 52, size: 16, color: palette.muted });
- });
- if (spec.note) {
- addText(slide, { text: spec.note, left: 96, top: 596, width: 1040, height: 28, size: 18, bold: true, color: palette.cinnabar, align: "center" });
- }
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- function section(presentation, spec, ctx) {
- const slide = presentation.slides.add();
- bg(slide, "dark");
- addText(slide, { text: spec.number, left: 74, top: 78, width: 200, height: 90, size: 74, bold: true, color: palette.jadeSoft, typeface: FONT.mono });
- addText(slide, { text: spec.title, left: 78, top: 210, width: 760, height: 120, size: 48, bold: true, color: palette.paper, typeface: FONT.title });
- addText(slide, { text: spec.text, left: 82, top: 360, width: 690, height: 72, size: 23, color: "#CADBEA" });
- addShape(slide, { left: 880, top: 120, width: 240, height: 420, fill: "#FFFFFF0F", border: "#FFFFFF22", borderWidth: 1 });
- addText(slide, { text: spec.side, left: 910, top: 206, width: 180, height: 230, size: 30, bold: true, color: palette.paper, align: "center", valign: "mid" });
- addFooter(slide, spec.footer, ctx.index, ctx.total);
- return slide;
- }
- const theorySlides = [
- {
- type: "cover",
- kicker: "理论课 01",
- title: "VOC 不是抓数据",
- subtitle: "它是一条从用户声音到经营判断、内容动作和复盘指标的证据链。",
- rightTitle: "社区课定位",
- rightText: "免费技能用于 OPC / 测试\n付费技能用于企业级稳定交付",
- footer: "VOC 理论课",
- },
- {
- type: "agenda",
- kicker: "本节学习路线",
- title: "先建立判断框架,再进入工具",
- subtitle: "学员要先知道为什么采、采什么、采完怎么判断。",
- items: [
- { title: "为什么", text: "从经验判断转向证据判断", accent: palette.ink },
- { title: "看什么", text: "平台声音、评论追问和转化阻力", accent: palette.jade },
- { title: "怎么分层", text: "OPC / 测试 / 企业级场景", accent: palette.cinnabar },
- { title: "交付什么", text: "问题池、行动计划、报告", accent: palette.gold },
- ],
- footer: "VOC 理论课",
- },
- {
- type: "claim",
- kicker: "课程开场问题",
- title: "老板真正缺的不是更多内容,而是可验证的判断",
- text: "如果没有用户证据,短视频脚本、产品建议和活动方案都会变成拍脑袋。",
- proofTitle: "证据链",
- proofText: "用户原话\n→ 问题聚类\n→ 优先级\n→ 动作\n→ 指标复盘",
- accent: palette.cinnabar,
- footer: "VOC 理论课",
- },
- {
- type: "compare",
- kicker: "概念纠偏",
- title: "VOC 和普通市场分析的区别",
- subtitle: "这页要让学员明白:VOC 的核心不是结论漂亮,而是能追溯。",
- columns: [
- {
- title: "普通分析",
- body: "先写观点,再找行业常识支撑。\n容易出现:空泛、像模板、无法落地。",
- bottom: "结果:看完觉得对,但不知道明天改什么。",
- accent: palette.muted,
- fill: palette.mist2,
- },
- {
- title: "VOC 分析",
- body: "先抓用户表达,再归纳问题和阻力。\n每个建议都能回到样本、评论、关键词。",
- bottom: "结果:能做动作、能验证、能复盘。",
- accent: palette.jade,
- fill: palette.paper,
- },
- ],
- footer: "VOC 理论课",
- },
- {
- type: "section",
- number: "01",
- title: "先讲清业务场景",
- text: "采集前不先问平台,而是先问:这次要改善哪个经营结果?",
- side: "业务问题\n决定数据质量",
- footer: "VOC 理论课",
- },
- {
- type: "matrix",
- kicker: "采集前置条件",
- title: "一条合格 VOC 任务,至少要有 4 个输入",
- subtitle: "输入越像业务问题,输出越像经营情报;输入越像泛泛关键词,输出越像闲聊。",
- headers: ["输入", "茶饮案例", "为什么重要", "换行业怎么填"],
- rows: [
- ["业务目标", "提升新客转化", "决定报告回答什么", "咨询转化 / 复购 / 客诉"],
- ["决策门槛", "18-22 元价格带", "决定用户比较方式", "客单价 / 合同金额 / 使用成本"],
- ["核心问题", "值不值、踩不踩雷", "决定关键词和判断口径", "怕增项 / 怕无效 / 怕售后"],
- ["用户原话", "茶底是什么?我要避雷", "防止凭经验发散", "评论、私信、差评、问答"],
- ],
- footer: "VOC 理论课",
- },
- {
- type: "flow",
- kicker: "VOC 小闭环",
- title: "不是采完就结束,而是跑完一轮证据闭环",
- subtitle: "这页是整门课的主流程,后面的实操课全部围绕它展开。",
- steps: [
- { title: "定义问题", text: "业务目标 + 用户阻力", accent: palette.ink },
- { title: "采集矩阵", text: "平台、人群、关键词", accent: palette.blue },
- { title: "真实采集", text: "样本、评论、链接", accent: palette.jade },
- { title: "问题池", text: "严重度和优先级", accent: palette.gold },
- { title: "单点深挖", text: "动作和验证指标", accent: palette.cinnabar },
- { title: "交付复盘", text: "内容、报告、补采", accent: palette.ink2 },
- ],
- bottom: "每一环都要能回到用户证据,而不是回到模型想象。",
- footer: "VOC 理论课",
- },
- {
- type: "compare",
- kicker: "技能组合定位",
- title: "免费技能和付费技能,不是互相替代",
- subtitle: "它们服务于不同阶段:学习验证 vs 企业级稳定交付。",
- columns: [
- {
- title: "免费 / 开源技能",
- body: "适合 OPC、教学演示、小范围测试。\n重点是理解流程、验证方法、低成本起步。",
- bottom: "判断标准:能跑通,能形成初步洞察。",
- accent: palette.blue,
- fill: palette.mist2,
- },
- {
- title: "付费 VOC 技能",
- body: "适合稳定采集、大批量样本、企业报告。\n重点是权限、余额、错误处理、审计和复用。",
- bottom: "判断标准:能稳定交付,能复盘追溯。",
- accent: palette.cinnabar,
- fill: palette.paper,
- },
- ],
- footer: "VOC 理论课",
- },
- {
- type: "cards",
- kicker: "企业级场景为什么需要付费能力",
- title: "专业 VOC 交付要解决 6 件免费工具常常忽略的事",
- subtitle: "这页用于解释商业边界,不要讲成“免费不好”,而是讲“使用场景不同”。",
- cards: [
- { title: "稳定采集", text: "接口失败、限流、重试和余额状态可解释", accent: palette.cinnabar },
- { title: "批量样本", text: "多关键词、多平台、持续补采", accent: palette.ink },
- { title: "证据审计", text: "样本、评论、链接、批次能追溯", accent: palette.jade },
- { title: "错误修复", text: "关键词、token、余额和平台异常有明确下一步", accent: palette.gold },
- { title: "报告交付", text: "从趋势、问题池到 HTML 报告可复用", accent: palette.blue },
- { title: "团队协作", text: "老板、运营、内容、产品都能读懂", accent: palette.cinnabar },
- ],
- note: "企业级价值不是“多一个工具”,而是减少采集和交付过程中的不确定性。",
- footer: "VOC 理论课",
- },
- {
- type: "section",
- number: "02",
- title: "以茶饮新客转化为统一案例",
- text: "所有方法都要落回一个具体业务:18-22 元价格带,如何降低新客首单决策成本。",
- side: "值不值\n会不会踩雷\n怎么点最稳",
- footer: "VOC 理论课",
- },
- {
- type: "claim",
- kicker: "案例洞察",
- title: "18-22 元不是绝对贵,真正阻力是看不懂值在哪",
- titleSize: 42,
- text: "用户会比较套餐、券后价、自制成本、茶底、甜度、配料表和外卖到手体验。",
- proofTitle: "用户追问",
- proofText: "茶底是什么?\n甜度怎么选?\n买一送一有没有坑?\n外卖到手会翻车吗?",
- accent: palette.jade,
- footer: "VOC 理论课",
- },
- {
- type: "matrix",
- kicker: "平台分工",
- title: "抖音和小红书看到的用户心态不同",
- subtitle: "同一个业务问题,需要用不同平台补齐决策链路。",
- headers: ["平台", "更像什么", "重点看什么", "转成什么动作"],
- rows: [
- ["小红书", "做功课和避坑", "值不值、茶底、甜度、配料", "解释型笔记、避坑指南"],
- ["抖音", "触发注意和下单", "低价新品、团购、买一送一", "短视频钩子、活动规则"],
- ["评论区", "转化前验真", "怎么点、多少钱、会不会踩雷", "置顶回复、统一话术"],
- ["门店/外卖", "体验兑现", "出杯、排队、到手口感", "流程提示、外卖稳定款"],
- ],
- footer: "VOC 理论课",
- },
- {
- type: "cards",
- kicker: "报告不等于策略口号",
- title: "一份合格 VOC 报告至少包含 6 类内容",
- subtitle: "后面的实战课会把每一类都跑出来。",
- cards: [
- { title: "数据口径", text: "平台、关键词、样本数、评论数", accent: palette.ink },
- { title: "显著问题", text: "用户反复表达的阻力", accent: palette.cinnabar },
- { title: "证据样本", text: "原话、链接、互动和截图", accent: palette.jade },
- { title: "优先级", text: "按业务影响排序,不只看词频", accent: palette.gold },
- { title: "行动计划", text: "今天、7 天、长期沉淀", accent: palette.blue },
- { title: "验证指标", text: "评论变化、咨询、下单、复购", accent: palette.cinnabar },
- ],
- footer: "VOC 理论课",
- },
- {
- type: "claim",
- kicker: "学员最终要带走的能力",
- title: "看到一条评论,就能判断它该进入哪个经营环节",
- text: "不是把评论复制给 AI,而是识别它影响产品、价格、内容、服务还是信任。",
- proofTitle: "判断题",
- proofText: "“我要避雷”\n不是情绪词\n而是首单风险信号",
- accent: palette.cinnabar,
- dark: true,
- footer: "VOC 理论课",
- },
- {
- type: "checklist",
- kicker: "理论课收尾",
- title: "进入实战课前,学员需要记住 5 个标准",
- subtitle: "这页可以作为第一节课的课后检查。",
- items: [
- ["先业务", "先写经营目标,再写关键词", "避免乱采"],
- ["先 sample", "先跑通格式,再 live 采集", "降低卡点"],
- ["有证据", "判断必须能回到原话或样本", "防止空泛"],
- ["分场景", "免费用于测试,付费用于稳定交付", "边界清楚"],
- ["可验证", "每个建议都要有 7 天指标", "能复盘"],
- ],
- footer: "VOC 理论课",
- },
- {
- type: "section",
- number: "NEXT",
- title: "下一节:从 FmodeStudio 开始实操",
- text: "创建项目模板,安装技能,跑 sample,进入 live,生成问题池、脚本和 HTML 报告。",
- side: "打开工具\n开始交付",
- footer: "VOC 理论课",
- },
- ];
- const practicalSlides = [
- {
- type: "cover",
- kicker: "实战课 02",
- title: "FmodeStudio VOC 实操",
- subtitle: "从技能安装、项目模板创建,到小红书/抖音采集和 HTML 报告交付。",
- rightTitle: "两段演示",
- rightText: "免费技能安装与演示\n付费 VOC 技能企业级展示",
- accent: palette.jade,
- footer: "VOC 实战课",
- },
- {
- type: "agenda",
- kicker: "本节产出",
- title: "学员跟完后,要拿到一套能复用的项目样板",
- subtitle: "不是只看老师演示,而是知道自己下一次怎么换行业复跑。",
- items: [
- { title: "建项目", text: "FmodeStudio + 项目模板", accent: palette.ink },
- { title: "装技能", text: "免费技能与付费技能两条线", accent: palette.blue },
- { title: "跑采集", text: "sample、矩阵、live 小规模", accent: palette.jade },
- { title: "交付", text: "问题池、内容计划、HTML 报告", accent: palette.cinnabar },
- ],
- footer: "VOC 实战课",
- },
- {
- type: "flow",
- kicker: "完整演示路径",
- title: "实操课按 8 个动作推进",
- subtitle: "每一步都有截图点和通过标准,方便课程发布后学员独立复盘。",
- steps: [
- { title: "打开 Studio", text: "创建课程项目" },
- { title: "选模板", text: "VOC 项目结构" },
- { title: "装免费技能", text: "跑通 OPC/测试" },
- { title: "装付费技能", text: "workspace smoke" },
- { title: "sample", text: "先看格式" },
- { title: "live", text: "小规模真实采集" },
- ],
- bottom: "先证明流程可用,再追求样本规模;先小范围验证,再进入企业级采集。",
- footer: "VOC 实战课",
- },
- {
- type: "checklist",
- kicker: "课前准备",
- title: "开始前先检查这 5 项",
- subtitle: "这页是实操课开场,不要等出错了再补环境。",
- items: [
- ["FmodeStudio", "能打开并创建项目", "通过"],
- ["Claude Code", "能在项目内正常对话", "通过"],
- ["Node / npx", "终端能返回版本号", "通过"],
- ["截图文件夹", "为每个步骤留空截图", "通过"],
- [".env.local", "如有 token,不展示真实值", "通过"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "matrix",
- kicker: "项目模板",
- title: "VOC 项目模板建议包含这些文件夹",
- subtitle: "FmodeStudio 里创建项目后,学员能用这个结构沉淀材料。",
- headers: ["目录", "放什么", "为什么需要", "截图点"],
- rows: [
- ["01-inputs", "业务背景、关键词、用户原话", "防止忘记输入口径", "项目初始化"],
- ["02-samples", "sample 输出和第一轮观察", "先跑通流程", "sample 结果"],
- ["03-live-data", "live 样本、评论、链接", "保留真实证据", "工具输出"],
- ["04-analysis", "矩阵、问题池、深挖", "形成经营判断", "问题排序"],
- ["05-delivery", "脚本、HTML、复盘指标", "交付给老板/团队", "最终报告"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "section",
- number: "A",
- title: "免费技能安装与演示",
- text: "免费技能用于让学员理解技能调用、OPC 和小范围验证,不承诺企业级稳定采集。",
- side: "学习\n测试\n验证",
- footer: "VOC 实战课",
- },
- {
- type: "cards",
- kicker: "免费技能演示口径",
- title: "免费技能这一段要演示 4 件事",
- subtitle: "这里不要过度承诺,只让学员看懂技能如何进入业务流程。",
- cards: [
- { title: "找到技能", text: "在 FmodeStudio / Claude Code 中确认技能入口", accent: palette.blue },
- { title: "安装或启用", text: "按该免费技能的 README 操作", accent: palette.jade },
- { title: "跑 sample", text: "先用课程案例或内置样例输出", accent: palette.gold },
- { title: "读结果", text: "看是否有证据、边界和下一步", accent: palette.cinnabar },
- ],
- note: "如果免费技能名单暂未确定,这一页保留为“替换实际开源技能名称和链接”的通用演示页。",
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "免费技能演示 Prompt",
- title: "先让 Claude Code 明确使用当前免费技能",
- subtitle: "这段用于 OPC/测试,不进入大批量 live 采集。",
- label: "复制给 Claude Code",
- code: `请使用当前项目中已安装的免费 / 开源 VOC 技能,先不要做大批量真实采集。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:值不值、会不会踩雷、首单怎么点最稳。\n\n请先用 sample 或小范围测试数据输出:\n1. 第一轮初步判断\n2. 证据样本\n3. 用户顾虑\n4. 数据边界\n5. 下一步需要真实采集验证的问题`,
- size: 16,
- footer: "VOC 实战课",
- },
- {
- type: "compare",
- kicker: "免费技能的边界",
- title: "演示结束后,要把边界讲清楚",
- subtitle: "让学员知道什么时候免费技能够用,什么时候需要企业级能力。",
- columns: [
- {
- title: "够用的场景",
- body: "学习 VOC 方法\nOPC / 概念验证\n小样本跑流程\n检查 prompt 是否合理",
- bottom: "目标:理解流程和判断口径",
- accent: palette.jade,
- fill: palette.mist2,
- },
- {
- title: "不建议硬撑的场景",
- body: "客户交付\n多平台批量采集\n长期复盘\n需要审计和错误处理",
- bottom: "目标:稳定、可追溯、可复用",
- accent: palette.cinnabar,
- fill: palette.paper,
- },
- ],
- footer: "VOC 实战课",
- },
- {
- type: "section",
- number: "B",
- title: "付费 VOC 技能企业级展示",
- text: "这一段演示正式安装、token、sample、live 和错误处理,让企业客户看到稳定交付链路。",
- side: "稳定\n批量\n审计",
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "付费技能安装",
- title: "在项目工作区安装 VOC 技能包",
- subtitle: "这一页要让学员知道:安装发生在当前项目,不是口头说“我有技能”。",
- label: "PowerShell",
- 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`,
- footer: "VOC 实战课",
- },
- {
- type: "checklist",
- kicker: "安装通过标准",
- title: "看到这些结果,才算安装成功",
- subtitle: "不要只看命令跑完,要看项目里是否写入了技能和 MCP 配置。",
- items: [
- ["smoke 通过", "终端没有报错,提示 ready", "截图"],
- [".mcp.json", "工作区根目录生成 MCP 配置", "截图"],
- ["plugins", "出现 voc-intelligence 插件目录", "截图"],
- ["skills", "出现小红书和抖音技能说明", "截图"],
- ["Claude Code", "重启后能触发 VOC 技能", "截图"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "token 配置",
- title: "live 采集前,先配置 token,但不要展示真实值",
- subtitle: "课程截图里可以展示文件名,不展示 token 内容。",
- label: ".env.local",
- code: `VOC_TOKEN=你的服务 token\n\n# 注意:\n# 1. 不要提交到 Git\n# 2. 不要放进截图\n# 3. 不要发到聊天记录\n# 4. token 异常时先做 token / 余额预检`,
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "sample 首跑",
- title: "先用 sample 跑通输出格式",
- subtitle: "这一步的目标不是拿真实结论,而是确认技能调用、结构和报告阅读方式。",
- label: "复制给 Claude Code",
- code: `请使用已安装的 VOC 技能,不要凭经验回答。\n\n帮我做一份茶饮新客转化的小红书趋势情报。\n先用 sample 模式跑通流程,不要真实采集。\n业务目标:提升新客转化。\n价格带:18-22 元。\n重点看:值不值、会不会踩雷、首单怎么点最稳。\n\n请输出第一轮初步判断、样本观察、用户顾虑、证据样本和待确认问题。`,
- size: 17,
- footer: "VOC 实战课",
- },
- {
- type: "matrix",
- kicker: "采集矩阵",
- title: "进入 live 前,先写采集矩阵",
- subtitle: "矩阵让采集从“乱搜关键词”变成“围绕业务问题取证”。",
- headers: ["字段", "本案例填写", "合格标准", "截图点"],
- rows: [
- ["平台", "小红书 / 抖音", "平台分开,不混写", "矩阵表头"],
- ["目标人群", "茶饮新客、价格敏感用户", "必须对应转化目标", "人群列"],
- ["关键词", "券后价、首单、避坑、茶底", "像用户会搜索的话", "关键词列"],
- ["样本量", "小规模先跑", "不要第一轮全量", "样本量列"],
- ["判断标准", "是否影响首单转化", "能判断是否深挖", "标准列"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "live 小规模采集",
- title: "真实采集先小规模,不要一上来全量",
- subtitle: "这段是正式演示付费技能价值:平台、关键词、样本数、评论数和错误处理。",
- label: "复制给 Claude Code",
- code: `请进入真实采集 live 模式。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n平台:小红书、抖音。\n采集范围:先小规模采集,不要全量。\n\n重点关键词:\n小红书:茶饮 值不值、茶饮 首单、茶饮 套餐 点单、茶饮 避坑、茶饮 茶底 甜度\n抖音:茶饮 性价比 新客、茶饮 团购、茶饮 买一送一、茶饮 券后价、奶茶 点单 避坑\n\n请先输出第一轮市场声音、样本数、评论数、用户顾虑、证据样本和待确认问题。`,
- size: 15,
- footer: "VOC 实战课",
- },
- {
- type: "cards",
- kicker: "采集结果怎么读",
- title: "live 结果出来后,不要立刻写最终策略",
- subtitle: "先判断数据质量,再决定是否补采。",
- cards: [
- { title: "样本数", text: "看笔记/视频数量是否足够", accent: palette.ink },
- { title: "评论数", text: "看是否有真实追问", accent: palette.jade },
- { title: "显著问题", text: "看是否集中到转化阻力", accent: palette.cinnabar },
- { title: "证据样本", text: "看能否回到原帖/原视频", accent: palette.gold },
- { title: "补采词", text: "看下一轮该补什么", accent: palette.blue },
- { title: "边界", text: "低样本只写阶段性信号", accent: palette.cinnabar },
- ],
- footer: "VOC 实战课",
- },
- {
- type: "flow",
- kicker: "从采集到交付",
- title: "后半段把结果转成 4 个交付物",
- subtitle: "学员看到这里要知道:采集只是开始,交付才是课程成果。",
- steps: [
- { title: "问题池", text: "Top 3 转化阻力" },
- { title: "单点深挖", text: "价格/性价比" },
- { title: "内容计划", text: "7 天短视频脚本" },
- { title: "HTML 报告", text: "老板/运营可读" },
- ],
- bottom: "每个交付物都要有:证据、动作、指标。",
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "问题池 Prompt",
- title: "把采集结果整理成 VOC 问题池",
- subtitle: "问题池要按业务影响排序,不要只按词频排序。",
- label: "复制给 Claude Code",
- code: `请把上面的采集结果整理成 VOC 问题池。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n重点影响:新客转化、客单结构、评论信任。\n\n要求:\n1. 按业务影响排序,不要只按词频排序\n2. 每个问题都要有用户证据\n3. 标注影响环节\n4. 给出建议动作\n5. 输出 Top 3 问题和下一步是否适合深挖`,
- size: 17,
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "单点深挖 Prompt",
- title: "选择 Top 1:价格/性价比",
- subtitle: "不要建议单纯降价,要把价格解释成点单路径、价值证据和避坑说明。",
- label: "复制给 Claude Code",
- code: `请继续深挖这个 VOC 问题:价格/性价比。\n\n业务背景:茶饮连锁,产品定价在 18-22 元区间。\n主要目标:提升新客转化。\n\n证据:\n1. 探店内容别只拍氛围,评论区最关心值不值\n2. 茶底是什么?我要避雷\n3. 低价新品、11.9 单人下午茶、21 元买一送一能触发注意,但仍需解释规则、口味和是否有坑。\n\n请输出表层问题、深层问题、今天动作、7 天验证动作、评论区回复方向和验证指标。`,
- size: 15,
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "内容计划 Prompt",
- title: "把 VOC 问题变成 7 天短视频计划",
- subtitle: "每条内容都必须绑定真实用户问题,不写泛泛爆款标题。",
- label: "复制给 Claude Code",
- code: `请基于下面 VOC 背景,生成 7 天短视频内容计划和可直接拍摄的口播脚本。\n\n业务目标:提升茶饮新客转化。\n价格带:18-22 元。\n核心问题:用户判断“值不值”“会不会踩雷”“首单怎么点最稳”。\n\n每条内容包含:对应 VOC 问题、标题、前 3 秒钩子、镜头建议、口播脚本、评论区引导、验证指标。\n\n方向覆盖:第一次来怎么点;18-22 元值在哪;券后价和套餐;茶底甜度配料;避坑款;外卖到手;团购规则。`,
- size: 15,
- footer: "VOC 实战课",
- },
- {
- type: "prompt",
- kicker: "HTML 报告 Prompt",
- title: "HTML 报告让 Claude Code 直接生成文件",
- subtitle: "不要让它进入复杂 workflow;用前面材料直接生成可打开页面。",
- label: "复制给 Claude Code",
- code: `请基于前面 VOC 分析材料,直接生成一份老板/运营视角可阅读的 HTML 策略报告。\n\n要求:\n1. 使用基础样本数据、采集矩阵、问题池、单点深挖和内容计划\n2. 生成一个可打开的 HTML 文件\n3. 风格参考青花瓷:白底、瓷蓝、细线纹样、清爽高级\n4. 包含关键结论、采集矩阵、问题池、单点深挖、7 天内容计划、补采关键词\n5. 生成后请打开浏览器预览,并根据页面效果继续优化`,
- size: 16,
- footer: "VOC 实战课",
- },
- {
- type: "checklist",
- kicker: "常见报错",
- title: "实操中遇到错误,按状态判断下一步",
- subtitle: "这页要让学员不再被“余额不足/关键词错误/token 缺失”卡死。",
- items: [
- ["needs_token", "没有检测到 token", "配置 .env.local"],
- ["needs_recharge", "未开通或余额不足", "打开充值/开通链接"],
- ["关键词错误", "搜索接口不接受入参", "缩短关键词、加空格重试"],
- ["链接 404", "平台链接或原帖失效", "保留样本编号和截图"],
- ["工具不触发", "Claude Code 没调用技能", "明确要求使用 VOC 技能"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "compare",
- kicker: "课程商业表达",
- title: "免费技能满足学习需求,付费技能满足企业稳定需求",
- subtitle: "这页可以作为社区课转化页,但语气要克制。",
- columns: [
- {
- title: "买课用户",
- body: "能用免费技能或 sample 跑通 VOC 方法。\n重点是理解流程、学会判断、能做小范围分析。",
- bottom: "课程价值:学会做一次 VOC 小闭环。",
- accent: palette.jade,
- fill: palette.mist2,
- },
- {
- title: "企业用户",
- body: "需要稳定采集、批量样本、权限管理、错误处理、审计和报告复用。",
- bottom: "技能价值:稳定地把 VOC 变成交付。",
- accent: palette.cinnabar,
- fill: palette.paper,
- },
- ],
- footer: "VOC 实战课",
- },
- {
- type: "checklist",
- kicker: "实战课交付检查",
- title: "学员最终提交 6 个成果",
- subtitle: "这页作为实战课收尾,明确学完以后手上应该有什么。",
- items: [
- ["项目模板", "有 inputs / samples / live-data / analysis / delivery", "可复用"],
- ["技能可用", "免费技能演示 + 付费技能 smoke", "可截图"],
- ["采集矩阵", "平台、人群、关键词、样本量、标准", "可复跑"],
- ["问题池", "Top 3 问题和证据", "可决策"],
- ["内容计划", "7 天标题、口播、指标", "可拍摄"],
- ["HTML 报告", "老板/运营可直接阅读", "可交付"],
- ],
- footer: "VOC 实战课",
- },
- {
- type: "section",
- number: "END",
- title: "下一次换行业,只替换输入,不替换流程",
- text: "业务目标、价格门槛、用户原话、关键词、平台可以换;证据链和交付结构不要换。",
- side: "可复制\n可复盘\n可交付",
- footer: "VOC 实战课",
- },
- ];
- const renderers = { cover, agenda, claim, compare, flow, matrix, prompt, checklist, cards, section };
- async function saveBlobToFile(blob, filePath) {
- await fs.mkdir(path.dirname(filePath), { recursive: true });
- await fs.writeFile(filePath, Buffer.from(await blob.arrayBuffer()));
- }
- async function writeDeck({ fileName, slides }) {
- const artifact = await import(pathToFileURL(ARTIFACT_TOOL).href);
- const { Presentation, PresentationFile } = artifact;
- const presentation = Presentation.create({ slideSize: { width: W, height: H } });
- slides.forEach((spec, idx) => {
- renderers[spec.type](presentation, spec, { index: idx + 1, total: slides.length });
- });
- const base = fileName.replace(/\.pptx$/i, "");
- const deckPreviewDir = path.join(PREVIEW_DIR, base);
- const deckLayoutDir = path.join(LAYOUT_DIR, base);
- const deckQaDir = path.join(QA_DIR, base);
- await fs.mkdir(deckPreviewDir, { recursive: true });
- await fs.mkdir(deckLayoutDir, { recursive: true });
- await fs.mkdir(deckQaDir, { recursive: true });
- const previewPaths = [];
- for (let i = 0; i < presentation.slides.count; i += 1) {
- const slide = presentation.slides.getItem(i);
- const previewPath = path.join(deckPreviewDir, `slide-${String(i + 1).padStart(2, "0")}.png`);
- const png = await presentation.export({ slide, format: "png", scale: 1 });
- await saveBlobToFile(png, previewPath);
- previewPaths.push(previewPath);
- const layout = await presentation.export({ slide, format: "layout" });
- await fs.writeFile(
- path.join(deckLayoutDir, `slide-${String(i + 1).padStart(2, "0")}.layout.json`),
- await layout.text(),
- "utf8",
- );
- }
- const pptx = await PresentationFile.exportPptx(presentation);
- const outPath = path.join(OUT_DIR, fileName);
- await fs.mkdir(OUT_DIR, { recursive: true });
- await pptx.save(outPath);
- const contactSheet = path.join(deckQaDir, "contact-sheet.png");
- const cs = spawnSync(PYTHON, [MAKE_CONTACT_SHEET, "--output", contactSheet, ...previewPaths], {
- encoding: "utf8",
- });
- if (cs.status !== 0) {
- throw new Error(`contact sheet failed\n${cs.stdout}\n${cs.stderr}`);
- }
- return { outPath, previewDir: deckPreviewDir, layoutDir: deckLayoutDir, contactSheet, slides: slides.length };
- }
- async function main() {
- await fs.mkdir(WORKSPACE, { recursive: true });
- const theory = await writeDeck({
- fileName: "VOC理论课-从用户声音到经营证据链.pptx",
- slides: theorySlides,
- });
- const practical = await writeDeck({
- fileName: "VOC实战课-FmodeStudio技能项目模板.pptx",
- slides: practicalSlides,
- });
- const manifest = { generatedAt: new Date().toISOString(), workspace: WORKSPACE, decks: [theory, practical] };
- await fs.writeFile(path.join(WORKSPACE, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
- console.log(JSON.stringify(manifest, null, 2));
- }
- main().catch((error) => {
- console.error(error.stack || error.message || String(error));
- process.exit(1);
- });
|