docs: add procurement system presentation
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# 老板汇报 PPT 与流程图
|
||||
|
||||
## 交付物
|
||||
|
||||
- `roubao-procurement-overview.pptx`:8 页中文老板汇报。
|
||||
- `roubao-procurement-overview.pdf`:便于无演示软件环境直接预览和转发。
|
||||
- `roubao-system-flow.svg`:可继续编辑的独立流程图。
|
||||
- `roubao-system-flow.png`:适合直接插入聊天、文档或其他 PPT 的流程图。
|
||||
- `assets/`:PPT 使用的 T-202 已确认原型截图。
|
||||
- `generate.cjs`:可复现生成脚本。
|
||||
|
||||
PPT 的重点是业务作用、流程、安全边界和下一阶段决策,不把数据库或接口细节放在主
|
||||
汇报页面。原型截图仅用于表达已确认布局和交互,不代表生产 UI 已完成。
|
||||
|
||||
## 页面结构
|
||||
|
||||
1. 项目定位与当前阶段。
|
||||
2. 人工痛点和系统价值。
|
||||
3. 端到端流程图。
|
||||
4. AI 的受控使用方式。
|
||||
5. 管理 Web 与 Android 两端分工。
|
||||
6. 当前完成情况和路线图。
|
||||
7. 不下单、不支付等安全边界。
|
||||
8. 后置数据闭环和需要确认的资源。
|
||||
|
||||
## 重新生成
|
||||
|
||||
依赖安装在被 Git 忽略的 `.local/`,不会进入仓库:
|
||||
|
||||
```powershell
|
||||
npm install --prefix .local/ppt-tools --no-audit --no-fund `
|
||||
pptxgenjs@4.0.1 sharp@0.35.3
|
||||
node docs/ppt/generate.cjs
|
||||
```
|
||||
|
||||
Windows 已安装 PowerPoint/WPS 时,可将 PPTX 另存为 PDF 或导出每页图片做最终视觉
|
||||
检查。若演示软件只能可靠导出 PDF,可安装本地渲染依赖后逐页检查:
|
||||
|
||||
```powershell
|
||||
npm install --prefix .local/ppt-tools --no-audit --no-fund `
|
||||
pdfjs-dist@6.1.200 @napi-rs/canvas@1.0.2
|
||||
node docs/ppt/render-pdf.mjs `
|
||||
docs/ppt/roubao-procurement-overview.pdf .local/ppt-render
|
||||
node docs/ppt/generate.cjs --montage `
|
||||
.local/ppt-render .local/ppt-render/montage.png
|
||||
```
|
||||
|
||||
流程图使用 SVG 作为可编辑源,PNG 由同一脚本生成。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,920 @@
|
||||
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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createRequire } from "module";
|
||||
import { pathToFileURL } from "url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname.slice(1)), "..", "..");
|
||||
const toolRoot = path.join(repoRoot, ".local", "ppt-tools", "node_modules");
|
||||
const canvasModule = require(path.join(toolRoot, "@napi-rs", "canvas"));
|
||||
|
||||
globalThis.DOMMatrix = canvasModule.DOMMatrix;
|
||||
globalThis.ImageData = canvasModule.ImageData;
|
||||
globalThis.Path2D = canvasModule.Path2D;
|
||||
|
||||
const pdfjsPath = path.join(toolRoot, "pdfjs-dist", "legacy", "build", "pdf.mjs");
|
||||
const pdfjs = await import(pathToFileURL(pdfjsPath).href);
|
||||
|
||||
const [pdfArgument, outputArgument] = process.argv.slice(2);
|
||||
if (!pdfArgument || !outputArgument) {
|
||||
throw new Error("Usage: node render-pdf.mjs <input.pdf> <output-dir>");
|
||||
}
|
||||
|
||||
const pdfPath = path.resolve(pdfArgument);
|
||||
const outputDir = path.resolve(outputArgument);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const data = new Uint8Array(fs.readFileSync(pdfPath));
|
||||
const document = await pdfjs.getDocument({
|
||||
data,
|
||||
disableFontFace: true,
|
||||
isEvalSupported: false,
|
||||
useSystemFonts: true,
|
||||
}).promise;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
|
||||
const page = await document.getPage(pageNumber);
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const scale = 1600 / baseViewport.width;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasModule.createCanvas(
|
||||
Math.round(viewport.width),
|
||||
Math.round(viewport.height),
|
||||
);
|
||||
const context = canvas.getContext("2d");
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const outputPath = path.join(outputDir, `slide${pageNumber}.png`);
|
||||
fs.writeFileSync(outputPath, canvas.toBuffer("image/png"));
|
||||
}
|
||||
|
||||
console.log(`Rendered ${document.numPages} pages to ${outputDir}`);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,157 @@
|
||||
<?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>
|
||||
<line x1="370" y1="273" x2="445" y2="273" class="arrow"/>
|
||||
<line x1="755" y1="273" x2="830" y2="273" class="arrow"/>
|
||||
<line x1="1140" y1="273" x2="1215" y2="273" class="arrow"/>
|
||||
<line x1="1375" y1="368" x2="1375" y2="485" class="arrow"/>
|
||||
<line x1="1225" y1="580" x2="1150" y2="580" class="arrow"/>
|
||||
<line x1="840" y1="580" x2="765" y2="580" class="arrow"/>
|
||||
<line x1="455" y1="580" x2="380" y2="580" class="arrow"/>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="188" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="70" y="188" width="9" height="170" rx="4.5" fill="#D6654F"/>
|
||||
<circle cx="108" cy="225" r="19" fill="#D6654F"/>
|
||||
<text x="108" y="232" text-anchor="middle" class="num">1</text>
|
||||
<rect x="266" y="207" width="82" height="27" rx="13.5"
|
||||
fill="#D6654F" fill-opacity="0.12"/>
|
||||
<text x="307" y="226" text-anchor="middle"
|
||||
class="actor" fill="#D6654F">管理端</text>
|
||||
<text x="95" y="273" class="cardTitle">创建采购任务</text>
|
||||
<text x="95" y="307" class="detail"><tspan x="95" dy="0">标题、SKU、数量、描述</tspan><tspan x="95" dy="25">与参考图进入统一后端</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="455" y="188" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="455" y="188" width="9" height="170" rx="4.5" fill="#0D7A63"/>
|
||||
<circle cx="493" cy="225" r="19" fill="#0D7A63"/>
|
||||
<text x="493" y="232" text-anchor="middle" class="num">2</text>
|
||||
<rect x="651" y="207" width="82" height="27" rx="13.5"
|
||||
fill="#0D7A63" fill-opacity="0.12"/>
|
||||
<text x="692" y="226" text-anchor="middle"
|
||||
class="actor" fill="#0D7A63">Android</text>
|
||||
<text x="480" y="273" class="cardTitle">App 手动领取</text>
|
||||
<text x="480" y="307" class="detail"><tspan x="480" dy="0">设备就绪后点击获取</tspan><tspan x="480" dy="25">后端原子分配且只领一条</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="840" y="188" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="840" y="188" width="9" height="170" rx="4.5" fill="#28779A"/>
|
||||
<circle cx="878" cy="225" r="19" fill="#28779A"/>
|
||||
<text x="878" y="232" text-anchor="middle" class="num">3</text>
|
||||
<rect x="1036" y="207" width="82" height="27" rx="13.5"
|
||||
fill="#28779A" fill-opacity="0.12"/>
|
||||
<text x="1077" y="226" text-anchor="middle"
|
||||
class="actor" fill="#28779A">VLM</text>
|
||||
<text x="865" y="273" class="cardTitle">解析图片与文字</text>
|
||||
<text x="865" y="307" class="detail"><tspan x="865" dy="0">VLM 提取搜索词和属性</tspan><tspan x="865" dy="25">硬约束保持原值</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="1225" y="188" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="1225" y="188" width="9" height="170" rx="4.5" fill="#D09A2D"/>
|
||||
<circle cx="1263" cy="225" r="19" fill="#D09A2D"/>
|
||||
<text x="1263" y="232" text-anchor="middle" class="num">4</text>
|
||||
<rect x="1421" y="207" width="82" height="27" rx="13.5"
|
||||
fill="#D09A2D" fill-opacity="0.12"/>
|
||||
<text x="1462" y="226" text-anchor="middle"
|
||||
class="actor" fill="#D09A2D">拼多多</text>
|
||||
<text x="1250" y="273" class="cardTitle">拼多多有界搜索</text>
|
||||
<text x="1250" y="307" class="detail"><tspan x="1250" dy="0">App 自动输入搜索词</tspan><tspan x="1250" dy="25">只检查前 5 个可见候选</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="1225" y="495" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="1225" y="495" width="9" height="170" rx="4.5" fill="#0D7A63"/>
|
||||
<circle cx="1263" cy="532" r="19" fill="#0D7A63"/>
|
||||
<text x="1263" y="539" text-anchor="middle" class="num">5</text>
|
||||
<rect x="1421" y="514" width="82" height="27" rx="13.5"
|
||||
fill="#0D7A63" fill-opacity="0.12"/>
|
||||
<text x="1462" y="533" text-anchor="middle"
|
||||
class="actor" fill="#0D7A63">Android</text>
|
||||
<text x="1250" y="580" class="cardTitle">逐个采集证据</text>
|
||||
<text x="1250" y="614" class="detail"><tspan x="1250" dy="0">进入候选详情</tspan><tspan x="1250" dy="25">保存脱敏截图与原始顺序</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="840" y="495" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="840" y="495" width="9" height="170" rx="4.5" fill="#28779A"/>
|
||||
<circle cx="878" cy="532" r="19" fill="#28779A"/>
|
||||
<text x="878" y="539" text-anchor="middle" class="num">6</text>
|
||||
<rect x="1036" y="514" width="82" height="27" rx="13.5"
|
||||
fill="#28779A" fill-opacity="0.12"/>
|
||||
<text x="1077" y="533" text-anchor="middle"
|
||||
class="actor" fill="#28779A">VLM</text>
|
||||
<text x="865" y="580" class="cardTitle">逐个评估候选</text>
|
||||
<text x="865" y="614" class="detail"><tspan x="865" dy="0">输出匹配、缺失、拒绝原因</tspan><tspan x="865" dy="25">以及分数和置信度</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="455" y="495" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="455" y="495" width="9" height="170" rx="4.5" fill="#D6654F"/>
|
||||
<circle cx="493" cy="532" r="19" fill="#D6654F"/>
|
||||
<text x="493" y="539" text-anchor="middle" class="num">7</text>
|
||||
<rect x="651" y="514" width="82" height="27" rx="13.5"
|
||||
fill="#D6654F" fill-opacity="0.12"/>
|
||||
<text x="692" y="533" text-anchor="middle"
|
||||
class="actor" fill="#D6654F">采购人员</text>
|
||||
<text x="480" y="580" class="cardTitle">人工确认结论</text>
|
||||
<text x="480" y="614" class="detail"><tspan x="480" dy="0">接受、拒绝、无匹配或转人工</tspan><tspan x="480" dy="25">自动化保持硬停止</tspan></text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="70" y="495" width="300" height="170" rx="14"
|
||||
fill="#FFFFFF" stroke="#CAD4CE" stroke-width="2"/>
|
||||
<rect x="70" y="495" width="9" height="170" rx="4.5" fill="#34443D"/>
|
||||
<circle cx="108" cy="532" r="19" fill="#34443D"/>
|
||||
<text x="108" y="539" text-anchor="middle" class="num">8</text>
|
||||
<rect x="266" y="514" width="82" height="27" rx="13.5"
|
||||
fill="#34443D" fill-opacity="0.12"/>
|
||||
<text x="307" y="533" text-anchor="middle"
|
||||
class="actor" fill="#34443D">后端</text>
|
||||
<text x="95" y="580" class="cardTitle">回传结果与证据</text>
|
||||
<text x="95" y="614" class="detail"><tspan x="95" dy="0">后台展示状态、候选和理由</tspan><tspan x="95" dy="25">后续形成优化数据</tspan></text>
|
||||
</g>
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
Reference in New Issue
Block a user