Skip to content
Merged
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pnpm-lock.yaml
**/dist/
data/
**/*.tsbuildinfo
**/vendor/**
43 changes: 42 additions & 1 deletion apps/server/src/routes/tasks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Router, type Request, type Response } from "express";
import express, { Router, type Request, type Response } from "express";
import fs from "node:fs";
import path from "node:path";
import { config } from "../config.js";
import { tasks, reservations } from "../db.js";
import { allow } from "../services/ratelimit.js";
import { queue } from "../services/queue.js";
import { shotPath } from "../services/screenshot.js";
import { taskLog } from "../util/logger.js";
import { pickStyle } from "../services/styleHint/index.js";
import { fullDomain } from "../util/domain.js";
import { newTaskId, isValidDeviceId } from "../util/ids.js";
Expand Down Expand Up @@ -172,6 +173,46 @@ tasksRouter.get("/:id/screenshot", (req: Request, res: Response) => {
res.type("png").send(fs.readFileSync(file));
});

/**
* 收浏览器端拍的产物截图(/start done 步的 ShotCapture 组件用 html-to-image
* 截 420×760@2x 后原样 POST 过来;puppeteer 未装时这是截图的唯一来源)。
* 收的是 PNG 二进制而非 JSON——全局 express.json 只放 64kb,路由级 raw 单独放宽。
* 只许首次写入:已有截图的任务拒绝,防止任意客户端覆盖别人的图。
*/
tasksRouter.post(
"/:id/screenshot",
express.raw({ type: "image/png", limit: "6mb" }),
(req: Request, res: Response) => {
const t = tasks.get(req.params.id);
if (!t || !TERMINAL.has(t.status)) {
res.status(404).json({ error: "任务不存在或未完成" });
return;
}
if (t.screenshot) {
res.status(409).json({ error: "截图已存在" });
return;
}
const buf = req.body as Buffer;
const isPng =
Buffer.isBuffer(buf) &&
buf.length > 8 &&
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47;
if (!isPng) {
res.status(400).json({ error: "仅接受 PNG" });
return;
}
const file = shotPath(t.id);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buf);
tasks.update({ id: t.id, screenshot: 1 });
taskLog(t.id, `浏览器端截图已上传(${Math.round(buf.length / 1024)}KB)`);
res.json({ ok: true });
},
);

/** 凭证数据 */
tasksRouter.get("/:id/certificate", (req: Request, res: Response) => {
const t = tasks.get(req.params.id);
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/services/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,17 @@ function extractHtmlFromStdout(stdout: string): string | null {
return html.length > 512 && /<html|<!doctype/i.test(html) ? html : null;
}

/** mock:3s 后产出内置示例页(无需 codex,本地开发/演练用) */
/** mock:3s 后产出内置示例页(无需 codex,本地开发/演练用)。
* 描述里含 MOCK_FAIL 则模拟一次失败(E2E 验证失败邮件路径;仅 mock 生效) */
async function mockGenerate(opts: GenOptions): Promise<GenResult> {
const userText = fs.readFileSync(
path.join(opts.workdir, "prompt.txt"),
"utf-8",
);
if (userText.includes("MOCK_FAIL")) {
await new Promise((r) => setTimeout(r, 1500));
return { ok: false, error: "mock 模拟失败(MOCK_FAIL)" };
}
await new Promise((r) => setTimeout(r, 3000));
const htmlPath = path.join(opts.workdir, "index.html");
fs.writeFileSync(htmlPath, mockHtml(userText));
Expand Down
84 changes: 84 additions & 0 deletions apps/server/src/services/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,87 @@ export async function sendCompletionMail(p: {
);
}
}

/**
* 生成失败通知邮件(2026-09-20 电脑端改异步后补:用户提交即离开,
* 失败时页面上明确的负反馈他看不到——必须靠邮件通知,否则石沉大海)。
* 内容只说人话:出问题了、找工作人员重试;技术错误留给 taskLog/admin。
*/
export function buildFailureMail(p: {
domainLabel: string;
prompt: string;
lang: "zh" | "en";
}): { subject: string; body: string } {
const t = {
zh: {
subject: `你的网页这次没做出来 · ${p.domainLabel}`,
bar: "生成遇到问题",
title: "这次没做出来",
intro: "你在「Words to Website」活动里描述的网页,生成时出了点问题:",
outro: "别灰心——拿着这封邮件到活动现场找工作人员,可以马上重新做一次。",
footer: "本邮件由活动系统自动发送",
},
en: {
subject: `Your page didn't make it this time · ${p.domainLabel}`,
bar: "GENERATION FAILED",
title: "This one didn't make it",
intro:
"The page you described at the Words to Website event ran into a problem while being generated:",
outro:
"No worries — show this email to the event staff and they'll get you a retry right away.",
footer: "Sent automatically by the event system",
},
}[p.lang];
const body = `
<div style="max-width:560px;margin:0 auto;font-family:system-ui,-apple-system,'PingFang SC',sans-serif;color:#1c1917">
<div style="background:#fff;border:3px solid #f7d447;border-radius:8px;overflow:hidden">
<div style="background:#1c1917;padding:22px 30px">
<div style="font-family:Consolas,Menlo,monospace;font-size:11px;letter-spacing:3px;color:#f7d447">${t.bar}</div>
<h1 style="margin:6px 0 0;font-size:21px;color:#fff;font-weight:700">${t.title}</h1>
</div>
<div style="padding:26px 30px">
<p style="margin:0;font-size:14px;color:#78716c">${t.intro}</p>
<p style="margin:14px 0;font-size:13px;color:#1c1917;background:#fafaf7;border:1px dashed #e7e5e0;border-radius:8px;padding:12px 14px">"${p.prompt}"</p>
<p style="margin:24px 0 0;font-size:14px;color:#1c1917">${t.outro}</p>
<hr style="border:0;border-top:1px solid #e7e5e0;margin:24px 0">
<p style="margin:0;font-family:Consolas,Menlo,monospace;font-size:11px;letter-spacing:1px;color:#78716c">Words to Website · Activity 3 · ${t.footer}</p>
</div>
</div>
</div>`;
return { subject: t.subject, body };
}

export async function sendFailureMail(p: {
taskId: string;
to: string;
domainLabel: string;
prompt: string;
lang: "zh" | "en";
}): Promise<void> {
const mail = getPoster();
if (!mail) {
taskLog(
p.taskId,
`邮件未配置(MAIL_WEBHOOK_URL 为空),跳过失败通知 → ${p.to}`,
);
return;
}
const { subject, body } = buildFailureMail(p);
try {
const res = await mail.send({
to: p.to,
subject,
body,
type: "html",
});
taskLog(
p.taskId,
`失败通知邮件已发送 → ${p.to}(messageId=${res.messageId ?? "-"}, status=${res.status})`,
);
} catch (err) {
taskLog(
p.taskId,
`失败通知邮件发送失败: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
14 changes: 13 additions & 1 deletion apps/server/src/services/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { validateHtmlFile, ensureAttribution } from "./validator.js";
import { sessionManager } from "./codexSession.js";
import { sessionLog } from "./sessionLog.js";
import { finalizeTask } from "./finalize.js";
import { sendFailureMail } from "./mailer.js";
import { taskLog, log } from "../util/logger.js";

type Job = { taskId: string; kind: "gen" | "publish" };
Expand Down Expand Up @@ -207,7 +208,8 @@ async function handleFailure(taskId: string, error: string) {
}
}

/** 终态失败:标 failed + 释放域名预约(域名可被再预约,admin retry 会重新抢) */
/** 终态失败:标 failed + 释放域名预约(域名可被再预约,admin retry 会重新抢);
* 用户已异步离场(2026-09-20 电脑端改造),页面的负反馈他看不到——补发失败邮件 */
async function failFinal(taskId: string, error: string) {
tasks.update({
id: taskId,
Expand All @@ -218,6 +220,16 @@ async function failFinal(taskId: string, error: string) {
});
reservations.releaseByTask(taskId);
taskLog(taskId, `最终失败(域名预约已释放): ${error}`);
const t = tasks.get(taskId);
if (t?.email) {
void sendFailureMail({
taskId,
to: t.email,
domainLabel: t.domain ?? taskId,
prompt: t.prompt,
lang: t.page_lang === "en" ? "en" : "zh",
});
}
}

export const queue = {
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/services/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { taskLog } from "../util/logger.js";

/**
* 大屏截图:发布成功后对线上 URL 截全页 PNG。
* 运行时按需加载 puppeteer(可选依赖,未安装则大屏自动降级为 iframe 实时预览):
*
* 主路径已改为**浏览器端截图**:/start done 步的 ShotCapture 组件用
* html-to-image 截产物页后 POST 到 routes/tasks.ts 的 `POST /:id/screenshot`,
* 部署机零额外依赖。本文件是可选的服务端补充(未装 puppeteer 时直接跳过):
* pnpm --filter @words2site/server add puppeteer
* 部署机首次运行会下载 Chromium,活动前务必预热。
* 装了需预热(首次下载 Chromium),可作为没有前端参与时的兜底。
*/

export function shotPath(taskId: string): string {
Expand Down
42 changes: 20 additions & 22 deletions apps/web/src/components/FilmCard.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import LazyFrame from "@/components/LazyFrame.vue";
import { demoPage } from "@/lib/demoPages";
import { demoShot } from "@/lib/demoPages";
import { apiUrl } from "@/lib/apiBase";
import { t } from "@/i18n";
import { DEFAULT_STYLE, isCardStyle, type CardStyle } from "@/lib/styleHint";
Expand All @@ -11,7 +12,9 @@ import { DEFAULT_STYLE, isCardStyle, type CardStyle } from "@/lib/styleHint";
* 远端压暗由父层通过 CSS 变量 --veil 控制(0=全亮)。
*
* styleHint 决定渲染哪种外壳(七式之一);缺省 archive 米纸衬底。
* 页面区:有截图用 <img>(性能),演示卡/无截图页才落 iframe(LazyFrame)。
* 页面区:演示卡走内置 SVG「截图」、真实卡有截图走服务端截图——
* 两者都是 <img>(GPU 合成便宜,滚动墙几十张同屏的性能命门);
* 只有真实页无截图才落 iframe(LazyFrame)兜底。
*
* 几何值全部对着定稿视觉稿(Desktop/CPU/大屏卡片样式七种_带文字.png)
* 量出来的百分比走,改动前先量再改(见 skill words2site-dev)。
Expand Down Expand Up @@ -42,21 +45,24 @@ const style: CardStyle = isCardStyle(props.styleHint)

/**
* 页面区的源,三档:
* - 有截图 → frameImg:<img> 直出(浏览器解码缓存,GPU 合成远比 iframe 便宜;
* 大屏几十张卡同时滚动时这是性能命门)
* - 演示卡 → frameSrc.srcdoc:iframe 内置示例页
* - 真实页无截图 → frameSrc.src:iframe 兜底(活动常态是人人有截图,此档近零)
* - 演示卡 → demoShot:内置 SVG「截图」data URI,<img> 直出(与生产截图同构)
* - 真实卡有截图 → frameImg:<img> 直出(浏览器解码缓存,GPU 合成远比
* iframe 便宜;大屏几十张卡同时滚动时这是性能命门)
* - 真实页无截图 → frameSrc.src:iframe 兜底(活动常态是人人有截图,近零)
*
* 必须是 computed:卡片实例按槽位 index 复用,hasScreenshot 会在轮询里
* 从 0 翻 1(用户浏览器上传截图后 15s 内),const 只在 setup 算一次就永远翻不过去。
*/
const frameImg =
props.demoIndex === undefined && props.hasScreenshot
? apiUrl(`/api/tasks/${props.taskId}/screenshot`)
: null;
const frameSrc =
const frameImg = computed(() =>
props.demoIndex !== undefined
? { srcdoc: demoPage(props.demoIndex ?? 0) }
? demoShot(props.demoIndex)
: props.hasScreenshot
? {}
: { src: apiUrl(`/api/tasks/${props.taskId}/html`) };
? apiUrl(`/api/tasks/${props.taskId}/screenshot`)
: null,
);
const frameSrc = computed(() =>
props.hasScreenshot ? {} : { src: apiUrl(`/api/tasks/${props.taskId}/html`) },
);

const cardTitle = props.domain ?? props.taskId;

Expand Down Expand Up @@ -112,7 +118,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -151,7 +156,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -303,7 +307,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -370,7 +373,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -438,7 +440,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -510,7 +511,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -625,7 +625,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down Expand Up @@ -671,7 +670,6 @@ const emBase = (props.w / 146) * 12.5;
v-if="frameImg"
:src="frameImg"
class="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
alt=""
/>
Expand Down
Loading
Loading