Files

921 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require("fs");
const path = require("path");
const repoRoot = path.resolve(__dirname, "..", "..");
const toolRoot = path.join(repoRoot, ".local", "ppt-tools", "node_modules");
const pptxgen = require(path.join(toolRoot, "pptxgenjs"));
const sharp = require(path.join(toolRoot, "sharp"));
const outputDir = __dirname;
const assetDir = path.join(outputDir, "assets");
const flowSvgPath = path.join(outputDir, "roubao-system-flow.svg");
const flowPngPath = path.join(outputDir, "roubao-system-flow.png");
const pptxPath = path.join(outputDir, "roubao-procurement-overview.pptx");
const FONT = "Microsoft YaHei";
const COLORS = {
ink: "17231E",
inkSoft: "34443D",
paper: "F7F8F4",
white: "FFFFFF",
mist: "E9EEEA",
line: "CAD4CE",
green: "0D7A63",
greenDark: "075B4A",
greenSoft: "DCEFE8",
coral: "D6654F",
coralDark: "9F3F31",
coralSoft: "F8E5E0",
amber: "D09A2D",
amberDark: "8B6414",
amberSoft: "F8EDCF",
blue: "28779A",
blueSoft: "DEEDF3",
red: "B4493C",
redSoft: "F6E1DE",
grey: "6C7771",
greySoft: "EFF1EE",
};
const SHADOW = {
type: "outer",
color: "000000",
opacity: 0.12,
blur: 2,
angle: 45,
distance: 1,
};
function xmlEscape(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function flowCard({ x, y, number, title, detailLines, actor, color, width = 300, height = 170 }) {
const detail = detailLines.map((line, index) =>
`<tspan x="${x + 25}" dy="${index === 0 ? 0 : 25}">${xmlEscape(line)}</tspan>`
).join("");
return `
<g filter="url(#shadow)">
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="14"
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
<rect x="${x}" y="${y}" width="9" height="${height}" rx="4.5" fill="#${color}"/>
<circle cx="${x + 38}" cy="${y + 37}" r="19" fill="#${color}"/>
<text x="${x + 38}" y="${y + 44}" text-anchor="middle" class="num">${number}</text>
<rect x="${x + width - 104}" y="${y + 19}" width="82" height="27" rx="13.5"
fill="#${color}" fill-opacity="0.12"/>
<text x="${x + width - 63}" y="${y + 38}" text-anchor="middle"
class="actor" fill="#${color}">${xmlEscape(actor)}</text>
<text x="${x + 25}" y="${y + 85}" class="cardTitle">${xmlEscape(title)}</text>
<text x="${x + 25}" y="${y + 119}" class="detail">${detail}</text>
</g>`;
}
function buildFlowSvg() {
const cards = [
{ x: 70, y: 188, number: 1, title: "创建采购任务", detailLines: ["标题、SKU、数量、描述", "与参考图进入统一后端"], actor: "管理端", color: COLORS.coral },
{ x: 455, y: 188, number: 2, title: "App 手动领取", detailLines: ["设备就绪后点击获取", "后端原子分配且只领一条"], actor: "Android", color: COLORS.green },
{ x: 840, y: 188, number: 3, title: "解析图片与文字", detailLines: ["VLM 提取搜索词和属性", "硬约束保持原值"], actor: "VLM", color: COLORS.blue },
{ x: 1225, y: 188, number: 4, title: "拼多多有界搜索", detailLines: ["App 自动输入搜索词", "只检查前 5 个可见候选"], actor: "拼多多", color: COLORS.amber },
{ x: 1225, y: 495, number: 5, title: "逐个采集证据", detailLines: ["进入候选详情", "保存脱敏截图与原始顺序"], actor: "Android", color: COLORS.green },
{ x: 840, y: 495, number: 6, title: "逐个评估候选", detailLines: ["输出匹配、缺失、拒绝原因", "以及分数和置信度"], actor: "VLM", color: COLORS.blue },
{ x: 455, y: 495, number: 7, title: "人工确认结论", detailLines: ["接受、拒绝、无匹配或转人工", "自动化保持硬停止"], actor: "采购人员", color: COLORS.coral },
{ x: 70, y: 495, number: 8, title: "回传结果与证据", detailLines: ["后台展示状态、候选和理由", "后续形成优化数据"], actor: "后端", color: COLORS.inkSoft },
];
const arrows = [
[370, 273, 445, 273],
[755, 273, 830, 273],
[1140, 273, 1215, 273],
[1375, 368, 1375, 485],
[1225, 580, 1150, 580],
[840, 580, 765, 580],
[455, 580, 380, 580],
].map(([x1, y1, x2, y2]) =>
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" class="arrow"/>`
).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="5" stdDeviation="7" flood-color="#17231E" flood-opacity="0.12"/>
</filter>
<marker id="arrowHead" markerWidth="10" markerHeight="10" refX="8" refY="4.5"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L9,4.5 L0,9 Z" fill="#64726B"/>
</marker>
<style>
text { font-family: "Microsoft YaHei", "SimHei", sans-serif; fill: #17231E; }
.eyebrow { font-size: 18px; font-weight: 700; letter-spacing: 0; fill: #0D7A63; }
.title { font-size: 42px; font-weight: 800; }
.subtitle { font-size: 20px; fill: #56645D; }
.num { font-size: 20px; font-weight: 800; fill: #FFFFFF; }
.actor { font-size: 14px; font-weight: 700; }
.cardTitle { font-size: 24px; font-weight: 800; }
.detail { font-size: 16px; fill: #56645D; }
.arrow { stroke: #64726B; stroke-width: 4; fill: none; marker-end: url(#arrowHead); }
.guard { font-size: 17px; font-weight: 700; fill: #17231E; }
.footer { font-size: 14px; fill: #6C7771; }
</style>
</defs>
<rect width="1600" height="900" fill="#F7F8F4"/>
<rect x="0" y="0" width="14" height="900" fill="#0D7A63"/>
<text x="70" y="64" class="eyebrow">END-TO-END FLOW · 第一版目标流程</text>
<text x="70" y="113" class="title">肉包智能采购验证系统</text>
<text x="70" y="149" class="subtitle">从后台任务到人工确认与证据回传;MVP 不提交订单、不支付</text>
${arrows}
${cards.map(flowCard).join("\n")}
<rect x="70" y="736" width="1460" height="88" rx="12" fill="#DCEFE8"/>
<text x="104" y="770" class="eyebrow">安全边界</text>
<circle cx="293" cy="779" r="5" fill="#0D7A63"/><text x="311" y="786" class="guard">采购人员手动领取</text>
<circle cx="533" cy="779" r="5" fill="#0D7A63"/><text x="551" y="786" class="guard">最多检查 5 个候选</text>
<circle cx="819" cy="779" r="5" fill="#D09A2D"/><text x="837" y="786" class="guard">验证码/风控立即停止</text>
<circle cx="1128" cy="779" r="5" fill="#D6654F"/><text x="1146" y="786" class="guard">人工确认后只回传结果</text>
<text x="70" y="866" class="footer">当前状态:T-205 后端原子领取与租约已完成 · 下一步:T-206 Android 接入</text>
<text x="1530" y="866" text-anchor="end" class="footer">2026-07-26</text>
</svg>`;
}
async function writeFlowAssets() {
fs.mkdirSync(outputDir, { recursive: true });
const svg = buildFlowSvg().replace(/[ \t]+$/gm, "");
fs.writeFileSync(flowSvgPath, svg, "utf8");
await sharp(Buffer.from(svg)).png().toFile(flowPngPath);
}
function addText(slide, text, x, y, w, h, options = {}) {
slide.addText(text, {
x, y, w, h,
fontFace: FONT,
fontSize: 16,
color: COLORS.ink,
margin: 0,
breakLine: false,
valign: "mid",
fit: "shrink",
...options,
});
}
function addRect(slide, x, y, w, h, fill, options = {}) {
slide.addShape(options.shape || "rect", {
x, y, w, h,
fill: { color: fill, transparency: options.transparency || 0 },
line: {
color: options.lineColor || fill,
transparency: options.lineTransparency === undefined ? 100 : options.lineTransparency,
width: options.lineWidth || 1,
},
radius: options.radius,
shadow: options.shadow,
});
}
function addRoundRect(slide, x, y, w, h, fill, options = {}) {
slide.addShape("roundRect", {
x, y, w, h,
rectRadius: options.rectRadius || 0.08,
fill: { color: fill, transparency: options.transparency || 0 },
line: {
color: options.lineColor || fill,
transparency: options.lineTransparency === undefined ? 100 : options.lineTransparency,
width: options.lineWidth || 1,
},
shadow: options.shadow,
});
}
function addLine(slide, x, y, w, h, color = COLORS.line, width = 1, endArrowType) {
slide.addShape("line", {
x, y, w, h,
line: {
color,
width,
beginArrowType: "none",
endArrowType: endArrowType || "none",
},
});
}
function addPill(slide, text, x, y, w, fill, color = COLORS.ink, borderColor) {
addRoundRect(slide, x, y, w, 0.34, fill, {
lineColor: borderColor || fill,
lineTransparency: borderColor ? 0 : 100,
lineWidth: 1,
});
addText(slide, text, x + 0.08, y + 0.01, w - 0.16, 0.3, {
fontSize: 10,
bold: true,
color,
align: "center",
});
}
function addSectionHeader(slide, page, eyebrow, title, subtitle) {
addText(slide, eyebrow.toUpperCase(), 0.62, 0.33, 5.5, 0.24, {
fontSize: 10,
bold: true,
color: COLORS.green,
charSpacing: 1.2,
});
addText(slide, title, 0.62, 0.64, 10.9, 0.54, {
fontSize: 26,
bold: true,
color: COLORS.ink,
});
if (subtitle) {
addText(slide, subtitle, 0.62, 1.15, 10.9, 0.34, {
fontSize: 12.5,
color: COLORS.grey,
});
}
addText(slide, String(page).padStart(2, "0"), 12.1, 0.43, 0.58, 0.38, {
fontSize: 12,
bold: true,
color: COLORS.grey,
align: "right",
});
addLine(slide, 0.62, 1.48, 12.05, 0, COLORS.line, 1);
}
function addFooter(slide, text = "肉包智能采购验证系统 · 内部汇报") {
addText(slide, text, 0.62, 7.16, 8.4, 0.16, {
fontSize: 8.5,
color: "79847E",
});
addText(slide, "2026-07-26", 11.72, 7.16, 0.95, 0.16, {
fontSize: 8.5,
color: "79847E",
align: "right",
});
}
function addNumberBadge(slide, number, x, y, color) {
slide.addShape("ellipse", {
x, y, w: 0.42, h: 0.42,
fill: { color },
line: { color, transparency: 100 },
});
addText(slide, number, x, y + 0.01, 0.42, 0.38, {
fontSize: 12,
bold: true,
color: COLORS.white,
align: "center",
});
}
function addCheckDot(slide, x, y, color = COLORS.green) {
slide.addShape("ellipse", {
x, y, w: 0.12, h: 0.12,
fill: { color },
line: { color, transparency: 100 },
});
}
function makeDeck() {
const pptx = new pptxgen();
pptx.layout = "LAYOUT_WIDE";
pptx.author = "Codex / cmroubao";
pptx.company = "cmroubao";
pptx.subject = "肉包智能采购验证系统老板汇报";
pptx.title = "肉包智能采购验证系统";
pptx.lang = "zh-CN";
pptx.theme = {
headFontFace: FONT,
bodyFontFace: FONT,
lang: "zh-CN",
};
pptx.defineLayout({ name: "CUSTOM_WIDE", width: 13.333, height: 7.5 });
pptx.layout = "CUSTOM_WIDE";
// 1. Cover
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.ink };
addRect(slide, 0, 0, 0.16, 7.5, COLORS.green);
addPill(slide, "老板汇报版 · 2026-07-26", 0.75, 0.55, 2.2, COLORS.green, COLORS.white);
addText(slide, "肉包智能采购\n验证系统", 0.75, 1.35, 6.2, 1.7, {
fontSize: 34,
bold: true,
color: COLORS.white,
breakLine: true,
valign: "top",
});
addText(slide, "把“创建任务、拼多多搜索、候选判断、人工确认”\n连成一条可控、可审计、可回传证据的采购验证流程", 0.78, 3.15, 6.05, 0.9, {
fontSize: 17,
color: "DCE5E0",
breakLine: true,
valign: "top",
breakLineOnTextOverflow: false,
});
addPill(slide, "当前:T-205 后端领取与租约完成", 0.78, 5.63, 2.78, COLORS.greenSoft, COLORS.greenDark);
addPill(slide, "下一步:T-206 Android 接入", 3.75, 5.63, 2.45, COLORS.coralSoft, COLORS.coralDark);
addText(slide, "MVP 边界:不自动下单 · 不支付 · 风控立即停止", 0.78, 6.27, 5.7, 0.35, {
fontSize: 12,
bold: true,
color: "E8B7AD",
});
const labels = [
{ text: "任务", color: COLORS.coral },
{ text: "领取", color: COLORS.green },
{ text: "搜索", color: COLORS.amber },
{ text: "评估", color: COLORS.blue },
{ text: "确认", color: COLORS.coral },
];
addLine(slide, 7.65, 3.55, 4.5, 0, "718078", 2);
labels.forEach((item, index) => {
const x = 7.45 + index * 1.14;
slide.addShape("ellipse", {
x, y: 3.2, w: 0.7, h: 0.7,
fill: { color: item.color },
line: { color: COLORS.ink, transparency: 100 },
shadow: SHADOW,
});
addText(slide, String(index + 1), x, 3.26, 0.7, 0.3, {
fontSize: 13,
bold: true,
color: COLORS.white,
align: "center",
});
addText(slide, item.text, x - 0.1, 4.02, 0.9, 0.3, {
fontSize: 12,
bold: true,
color: COLORS.white,
align: "center",
});
});
addText(slide, "统一后端", 9.18, 2.25, 2.1, 0.42, {
fontSize: 19,
bold: true,
color: COLORS.white,
align: "center",
});
addText(slide, "管理 Web + Android App + Go-Gin + VLM", 7.55, 4.75, 4.75, 0.38, {
fontSize: 12.5,
color: "C9D4CE",
align: "center",
});
addText(slide, "内部汇报", 11.3, 6.87, 1.2, 0.25, {
fontSize: 9,
color: "93A099",
align: "right",
});
}
// 2. Problem and value
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 2, "WHY THIS SYSTEM", "从重复人工比对,变成可控的验证闭环",
"目标不是替人付款,而是减少提词、搜索、比较和记录的重复劳动。");
addText(slide, "今天的人工流程", 0.7, 1.78, 3.5, 0.38, {
fontSize: 17,
bold: true,
color: COLORS.ink,
});
const manual = [
["看蝦皮文字和图片", "人工理解 SKU、数量和关键外观"],
["凭经验组合搜索词", "不同人使用不同口径"],
["逐个打开拼多多商品", "比较过程耗时且难复盘"],
["口头或零散记录结果", "证据、理由和失败位置分散"],
];
manual.forEach(([title, detail], index) => {
const y = 2.26 + index * 0.91;
addNumberBadge(slide, String(index + 1), 0.78, y, index === 3 ? COLORS.coral : COLORS.grey);
if (index < manual.length - 1) {
addLine(slide, 0.99, y + 0.42, 0, 0.5, COLORS.line, 2);
}
addText(slide, title, 1.36, y - 0.02, 3.75, 0.34, {
fontSize: 14,
bold: true,
});
addText(slide, detail, 1.36, y + 0.31, 3.85, 0.32, {
fontSize: 10.5,
color: COLORS.grey,
});
});
addRect(slide, 5.43, 1.75, 0.02, 4.8, COLORS.line);
addText(slide, "系统带来的变化", 5.9, 1.78, 4.2, 0.38, {
fontSize: 17,
bold: true,
color: COLORS.ink,
});
const values = [
{
num: "01",
title: "把输入变成统一任务",
detail: "标题、SKU、数量、预算和参考图保留原始事实,模型不能改写硬约束。",
color: COLORS.green,
soft: COLORS.greenSoft,
},
{
num: "02",
title: "把候选判断变成可解释过程",
detail: "最多检查 5 个可见候选,记录匹配项、缺失项、拒绝原因和截图证据。",
color: COLORS.blue,
soft: COLORS.blueSoft,
},
{
num: "03",
title: "把最终决定留给采购人员",
detail: "自动化在确认点硬停止;人员接受、拒绝或转人工后只回传验证结论。",
color: COLORS.coral,
soft: COLORS.coralSoft,
},
];
values.forEach((item, index) => {
const y = 2.27 + index * 1.18;
addRoundRect(slide, 5.88, y, 6.75, 0.93, item.soft, {
lineColor: item.color,
lineTransparency: 72,
});
addText(slide, item.num, 6.1, y + 0.12, 0.58, 0.3, {
fontSize: 17,
bold: true,
color: item.color,
});
addText(slide, item.title, 6.79, y + 0.1, 4.95, 0.3, {
fontSize: 14,
bold: true,
});
addText(slide, item.detail, 6.79, y + 0.43, 5.33, 0.34, {
fontSize: 10.5,
color: COLORS.inkSoft,
});
});
addRoundRect(slide, 5.88, 6.02, 6.75, 0.61, COLORS.ink, { shadow: SHADOW });
addText(slide, "业务结果:同一口径 · 有界执行 · 有据可查 · 可持续优化", 6.12, 6.13, 6.28, 0.31, {
fontSize: 13.5,
bold: true,
color: COLORS.white,
align: "center",
});
addFooter(slide);
}
// 3. Full-bleed flowchart
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
slide.addImage({ path: flowPngPath, x: 0, y: 0, w: 13.333, h: 7.5 });
}
// 4. AI decision process
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 4, "HOW AI IS USED", "AI 做识别和比较,不获得操作授权",
"模型输出受 schema 约束;搜索动作、推荐规则和人工结论由确定性代码控制。");
const stages = [
{ code: "A", title: "原始任务", detail: "文字 + 参考图\nSKU / 数量 / 预算", color: COLORS.coral, soft: COLORS.coralSoft },
{ code: "B", title: "需求解析", detail: "搜索词 / 类目 / 属性\n置信度与警告", color: COLORS.blue, soft: COLORS.blueSoft },
{ code: "C", title: "有界搜索", detail: "拼多多当前结果\n最多 5 个可见候选", color: COLORS.amber, soft: COLORS.amberSoft },
{ code: "D", title: "逐个评估", detail: "匹配 / 缺失 / 拒绝\nscore + confidence", color: COLORS.blue, soft: COLORS.blueSoft },
{ code: "E", title: "人工确认", detail: "接受 / 拒绝 / 无匹配\n自动化硬停止", color: COLORS.green, soft: COLORS.greenSoft },
];
stages.forEach((stage, index) => {
const x = 0.66 + index * 2.51;
addRoundRect(slide, x, 1.9, 2.12, 2.18, stage.soft, {
lineColor: stage.color,
lineTransparency: 65,
shadow: index === 4 ? SHADOW : undefined,
});
slide.addShape("ellipse", {
x: x + 0.16, y: 2.08, w: 0.48, h: 0.48,
fill: { color: stage.color },
line: { color: stage.color, transparency: 100 },
});
addText(slide, stage.code, x + 0.16, 2.12, 0.48, 0.33, {
fontSize: 12,
bold: true,
color: COLORS.white,
align: "center",
});
addText(slide, stage.title, x + 0.18, 2.74, 1.76, 0.37, {
fontSize: 15,
bold: true,
align: "center",
});
addText(slide, stage.detail, x + 0.18, 3.2, 1.76, 0.62, {
fontSize: 10.5,
color: COLORS.inkSoft,
align: "center",
breakLine: true,
valign: "top",
});
if (index < stages.length - 1) {
addLine(slide, x + 2.12, 2.99, 0.34, 0, COLORS.grey, 1.7, "triangle");
}
});
addRoundRect(slide, 0.72, 4.54, 6.03, 1.59, COLORS.white, {
lineColor: COLORS.line,
lineTransparency: 0,
shadow: SHADOW,
});
addPill(slide, "确定性约束", 0.96, 4.78, 1.14, COLORS.greenSoft, COLORS.greenDark);
const constraints = [
"SKU、数量和预算来自原任务,模型不得改写",
"推荐候选由本地规则按 score、confidence 和拒绝项产生",
"模型不能返回点击坐标、页面动作或订单授权",
];
constraints.forEach((text, index) => {
addCheckDot(slide, 1.0, 5.26 + index * 0.28, COLORS.green);
addText(slide, text, 1.2, 5.16 + index * 0.28, 5.15, 0.27, {
fontSize: 10.5,
color: COLORS.inkSoft,
});
});
addRoundRect(slide, 7.04, 4.54, 5.57, 1.59, COLORS.amberSoft, {
lineColor: COLORS.amber,
lineTransparency: 28,
});
addPill(slide, "口径提醒", 7.29, 4.78, 1.02, COLORS.amber, COLORS.white);
addText(slide, "“5 个候选”是特定账号、地区、时间和平台排序下的前 5 个可见结果,\n不是拼多多全平台最优 Top 5。", 7.31, 5.17, 4.92, 0.7, {
fontSize: 13,
bold: true,
color: COLORS.amberDark,
breakLine: true,
valign: "top",
});
addFooter(slide);
}
// 5. Product surfaces
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 5, "PRODUCT SURFACES", "管理端和采购端各做自己擅长的事",
"两端共享同一个 Go-Gin 后端;管理凭证和采购设备凭证完全隔离。");
addPill(slide, "管理 Web · 任务与证据", 0.72, 1.73, 1.78, COLORS.ink, COLORS.white);
addRoundRect(slide, 0.69, 2.17, 7.3, 4.12, COLORS.white, {
lineColor: COLORS.line,
lineTransparency: 0,
shadow: SHADOW,
});
slide.addImage({
path: path.join(assetDir, "admin-task-detail.png"),
x: 0.82, y: 2.3, w: 7.04, h: 3.78,
});
addText(slide, "创建/取消任务 · 查看执行状态 · 查看候选、理由和截图", 0.86, 6.39, 7.0, 0.28, {
fontSize: 10.5,
color: COLORS.inkSoft,
align: "center",
});
addPill(slide, "Android · 领取与人工确认", 8.53, 1.73, 2.1, COLORS.green, COLORS.white);
addRoundRect(slide, 8.47, 2.17, 3.22, 4.12, COLORS.white, {
lineColor: COLORS.line,
lineTransparency: 0,
shadow: SHADOW,
});
slide.addImage({
path: path.join(assetDir, "android-candidate-confirm.png"),
x: 8.85, y: 2.3, w: 2.46, h: 4.1,
});
addText(slide, "预检 · 手动领取 · 执行进度 · 安全停止", 8.38, 6.39, 3.48, 0.28, {
fontSize: 10.5,
color: COLORS.inkSoft,
align: "center",
});
addLine(slide, 8.06, 3.55, 0.34, 0, COLORS.green, 2, "triangle");
addText(slide, "统一后端", 7.83, 3.88, 0.78, 0.28, {
fontSize: 9.5,
bold: true,
color: COLORS.greenDark,
align: "center",
});
addPill(slide, "已确认低保真原型", 11.01, 1.73, 1.55, COLORS.greySoft, COLORS.grey, COLORS.line);
addFooter(slide, "画面为 T-202 已确认原型,用于表达布局与交互,不是生产截图");
}
// 6. Progress and roadmap
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 6, "STATUS & ROADMAP", "关键风险已验证,当前缺口是 Android 接入后端",
"后端 T-205 已完成;新版 APK 尚未安装,真实 VLM 供应商仍待确认。");
addLine(slide, 1.08, 2.42, 11.15, 0, COLORS.line, 4);
const milestones = [
{ x: 1.08, code: "T-101~104", title: "技术探针", detail: "搜索、5 候选\nVLM 评估", state: "完成", color: COLORS.green },
{ x: 3.77, code: "T-201~205", title: "后端闭环", detail: "管理端、鉴权\n原子领取/租约", state: "完成", color: COLORS.green },
{ x: 6.46, code: "T-206", title: "App 接入", detail: "登录、领取、进度\n装机联调", state: "下一步", color: COLORS.coral },
{ x: 9.15, code: "T-207", title: "结果回传", detail: "候选、事件\n截图与结果", state: "随后", color: COLORS.amber },
{ x: 11.84, code: "T-208/T-303", title: "数据与试验", detail: "人工理由\n20 条任务", state: "后置", color: COLORS.grey },
];
milestones.forEach((item) => {
slide.addShape("ellipse", {
x: item.x - 0.22, y: 2.18, w: 0.48, h: 0.48,
fill: { color: item.color },
line: { color: COLORS.paper, width: 2.2 },
});
addPill(slide, item.state, item.x - 0.42, 1.75, 0.86,
item.color === COLORS.grey ? COLORS.greySoft : item.color,
item.color === COLORS.grey ? COLORS.grey : COLORS.white,
item.color === COLORS.grey ? COLORS.line : undefined);
addText(slide, item.code, item.x - 0.6, 2.82, 1.2, 0.25, {
fontSize: 9.5,
bold: true,
color: item.color,
align: "center",
});
addText(slide, item.title, item.x - 0.72, 3.12, 1.44, 0.32, {
fontSize: 12.5,
bold: true,
align: "center",
});
addText(slide, item.detail, item.x - 0.78, 3.52, 1.56, 0.58, {
fontSize: 9.5,
color: COLORS.grey,
align: "center",
breakLine: true,
valign: "top",
});
});
addRoundRect(slide, 0.72, 4.57, 5.85, 1.63, COLORS.greenSoft, {
lineColor: COLORS.green,
lineTransparency: 72,
});
addPill(slide, "现在已经具备", 0.96, 4.8, 1.24, COLORS.green, COLORS.white);
const ready = [
"Android 能基于本地任务搜索、采集并评估最多 5 个候选",
"后台能创建任务,BUYER 设备能被授权并原子领取",
"租约、幂等、安全取消和参考图授权已实现",
];
ready.forEach((text, index) => {
addCheckDot(slide, 1.0, 5.29 + index * 0.27, COLORS.green);
addText(slide, text, 1.2, 5.19 + index * 0.27, 4.9, 0.26, {
fontSize: 10.2,
color: COLORS.greenDark,
});
});
addRoundRect(slide, 6.79, 4.57, 5.82, 1.63, COLORS.coralSoft, {
lineColor: COLORS.coral,
lineTransparency: 72,
});
addPill(slide, "现在还不能宣称", 7.03, 4.8, 1.44, COLORS.coral, COLORS.white);
const gaps = [
"后台任务已经自动进入 Android 并跑完整流程",
"真实 LLM 的商品匹配质量已达到业务门槛",
"当前看到的 5 个候选是全平台最优结果",
];
gaps.forEach((text, index) => {
addCheckDot(slide, 7.08, 5.29 + index * 0.27, COLORS.coral);
addText(slide, text, 7.28, 5.19 + index * 0.27, 4.86, 0.26, {
fontSize: 10.2,
color: COLORS.coralDark,
});
});
addFooter(slide);
}
// 7. Safety
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 7, "CONTROL & SAFETY", "安全不是附加项,是流程能否继续的条件",
"模型不能扩大动作权限;服务端状态、设备身份和人工确认共同约束自动化。");
addRoundRect(slide, 0.72, 1.83, 4.2, 4.73, COLORS.ink, { shadow: SHADOW });
addText(slide, "MVP", 1.06, 2.16, 1.0, 0.35, {
fontSize: 13,
bold: true,
color: "9FCFC0",
});
addText(slide, "不会自动\n下单或支付", 1.05, 2.66, 3.35, 1.3, {
fontSize: 27,
bold: true,
color: COLORS.white,
breakLine: true,
valign: "top",
});
addRoundRect(slide, 1.04, 4.31, 3.55, 0.66, COLORS.greenDark, {
lineColor: COLORS.green,
lineTransparency: 50,
});
addText(slide, "order_submitted = false", 1.18, 4.47, 3.25, 0.3, {
fontSize: 15,
bold: true,
color: COLORS.white,
align: "center",
fontFace: "Consolas",
});
addText(slide, "成功只表示验证流程完成并获得人员结论,\n不代表已购买商品。", 1.08, 5.35, 3.45, 0.64, {
fontSize: 11.5,
color: "CBD7D1",
breakLine: true,
valign: "top",
});
const safeguards = [
["01", "手动领取", "App 点击获取任务,不接受推送后无人确认地自动执行", COLORS.green],
["02", "有界浏览", "每次最多检查 5 个可见候选,不无限滑动或全站抓取", COLORS.blue],
["03", "风险即停", "验证码、登录失效、风控、未知页和支付页立即停止", COLORS.amber],
["04", "人工硬停止", "候选确认页不会恢复自动点击,人员决定接受或拒绝", COLORS.coral],
["05", "身份与租约", "ADMIN 与 BUYER/设备凭证隔离,错误设备不能继续任务", COLORS.green],
["06", "证据受控", "图片经鉴权、哈希和路径校验,不公开本地文件或任意 URL", COLORS.blue],
];
safeguards.forEach(([num, title, detail, color], index) => {
const col = index % 2;
const row = Math.floor(index / 2);
const x = 5.35 + col * 3.73;
const y = 1.94 + row * 1.46;
addText(slide, num, x, y, 0.45, 0.29, {
fontSize: 11,
bold: true,
color,
});
addText(slide, title, x + 0.54, y - 0.02, 2.5, 0.32, {
fontSize: 14,
bold: true,
});
addText(slide, detail, x + 0.54, y + 0.35, 2.85, 0.58, {
fontSize: 10,
color: COLORS.grey,
valign: "top",
});
addLine(slide, x, y + 1.1, 3.25, 0, COLORS.line, 1);
});
addRoundRect(slide, 5.35, 6.18, 7.23, 0.48, COLORS.redSoft, {
lineColor: COLORS.red,
lineTransparency: 65,
});
addText(slide, "验证码 / 风控 / 未知页面 / 支付边界 → 立即停止,不绕过、不猜测继续", 5.58, 6.27, 6.78, 0.28, {
fontSize: 11.5,
bold: true,
color: COLORS.red,
align: "center",
});
addFooter(slide);
}
// 8. Data loop and asks
{
const slide = pptx.addSlide();
slide.background = { color: COLORS.paper };
addSectionHeader(slide, 8, "NEXT DECISIONS", "先跑通第一版,再把每次人工判断变成可信数据",
"T-208 后置建设数据闭环;20 条真实任务试验只用人工结论评估模型。");
addText(slide, "后续数据闭环(T-208)", 0.74, 1.78, 4.1, 0.36, {
fontSize: 16.5,
bold: true,
});
const layers = [
{ code: "OBSERVATION", title: "候选观测", detail: "当时实际看到的标题、规格、价格、顺序与截图", color: COLORS.green, soft: COLORS.greenSoft },
{ code: "PREDICTION", title: "模型判断", detail: "匹配、缺失、拒绝原因、分数与模型版本", color: COLORS.blue, soft: COLORS.blueSoft },
{ code: "RECOMMENDATION", title: "系统推荐", detail: "确定性阈值、策略版本与推荐候选", color: COLORS.amber, soft: COLORS.amberSoft },
{ code: "HUMAN LABEL", title: "人工标签", detail: "选择、拒绝、改选或无匹配,以及结构化理由", color: COLORS.coral, soft: COLORS.coralSoft },
];
layers.forEach((layer, index) => {
const y = 2.24 + index * 0.92;
addRoundRect(slide, 0.75, y, 5.3, 0.7, layer.soft, {
lineColor: layer.color,
lineTransparency: 68,
});
addText(slide, layer.code, 0.98, y + 0.09, 1.35, 0.2, {
fontSize: 8.3,
bold: true,
color: layer.color,
});
addText(slide, layer.title, 2.42, y + 0.08, 1.08, 0.25, {
fontSize: 12.5,
bold: true,
});
addText(slide, layer.detail, 3.48, y + 0.08, 2.27, 0.45, {
fontSize: 9.4,
color: COLORS.inkSoft,
valign: "top",
});
if (index < layers.length - 1) {
addLine(slide, 3.39, y + 0.7, 0, 0.19, COLORS.grey, 1.4, "triangle");
}
});
addText(slide, "四层分开保存:模型理由不能自动成为人工理由", 0.9, 6.05, 5.0, 0.3, {
fontSize: 11,
bold: true,
color: COLORS.coralDark,
align: "center",
});
addText(slide, "需要确认的四件事", 6.56, 1.78, 3.7, 0.36, {
fontSize: 16.5,
bold: true,
});
const decisions = [
["1", "真实 VLM 供应商", "模型、测试凭证、调用预算和质量门槛"],
["2", "试验负责人", "指定采购人员,并统一“可接受/拒绝”的标注口径"],
["3", "数据保留规则", "截图保留期、隐私脱敏、平台条款和训练用途"],
["4", "20 条试验样本", "覆盖代表性 SKU、图片质量、预算和异常类型"],
];
decisions.forEach(([num, title, detail], index) => {
const y = 2.25 + index * 0.83;
addNumberBadge(slide, num, 6.6, y, index === 0 ? COLORS.coral : COLORS.green);
addText(slide, title, 7.19, y - 0.02, 2.25, 0.3, {
fontSize: 13,
bold: true,
});
addText(slide, detail, 9.36, y - 0.01, 3.0, 0.39, {
fontSize: 9.8,
color: COLORS.grey,
});
addLine(slide, 7.19, y + 0.51, 5.12, 0, COLORS.line, 1);
});
addRoundRect(slide, 6.56, 5.78, 5.98, 0.84, COLORS.ink, { shadow: SHADOW });
addText(slide, "建议批准的推进顺序", 6.82, 5.92, 1.65, 0.24, {
fontSize: 10,
bold: true,
color: "9FCFC0",
});
addText(slide, "T-206 App 接入 → T-207 结果回传 → T-208 数据闭环 → 20 条试验", 6.82, 6.19, 5.36, 0.27, {
fontSize: 11.5,
bold: true,
color: COLORS.white,
align: "center",
});
addFooter(slide);
}
return pptx;
}
async function createMontage(renderDir, outputPath) {
const files = fs.readdirSync(renderDir)
.filter((name) => /^slide\d+\.png$/i.test(name))
.sort((a, b) => {
const ai = Number(a.match(/\d+/)[0]);
const bi = Number(b.match(/\d+/)[0]);
return ai - bi;
});
if (files.length === 0) {
throw new Error(`No rendered slides found in ${renderDir}`);
}
const thumbWidth = 640;
const thumbHeight = 360;
const gap = 18;
const cols = 2;
const rows = Math.ceil(files.length / cols);
const canvasWidth = cols * thumbWidth + (cols + 1) * gap;
const canvasHeight = rows * thumbHeight + (rows + 1) * gap;
const composites = [];
for (let index = 0; index < files.length; index += 1) {
const input = path.join(renderDir, files[index]);
const buffer = await sharp(input)
.resize(thumbWidth, thumbHeight, { fit: "fill" })
.png()
.toBuffer();
composites.push({
input: buffer,
left: gap + (index % cols) * (thumbWidth + gap),
top: gap + Math.floor(index / cols) * (thumbHeight + gap),
});
}
await sharp({
create: {
width: canvasWidth,
height: canvasHeight,
channels: 4,
background: "#D7DDD9",
},
}).composite(composites).png().toFile(outputPath);
}
async function main() {
if (process.argv.includes("--montage")) {
const renderDir = process.argv[process.argv.indexOf("--montage") + 1];
const outputPath = process.argv[process.argv.indexOf("--montage") + 2];
if (!renderDir || !outputPath) {
throw new Error("Usage: node generate.cjs --montage <render-dir> <output.png>");
}
await createMontage(path.resolve(renderDir), path.resolve(outputPath));
return;
}
await writeFlowAssets();
const pptx = makeDeck();
await pptx.writeFile({ fileName: pptxPath });
console.log(`Generated ${pptxPath}`);
console.log(`Generated ${flowSvgPath}`);
console.log(`Generated ${flowPngPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});