diff --git a/.prettierignore b/.prettierignore
index 05a17fa..618645e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,4 @@ pnpm-lock.yaml
**/dist/
data/
**/*.tsbuildinfo
+**/vendor/**
diff --git a/apps/server/src/routes/tasks.ts b/apps/server/src/routes/tasks.ts
index 5e4efd4..5504b35 100644
--- a/apps/server/src/routes/tasks.ts
+++ b/apps/server/src/routes/tasks.ts
@@ -1,4 +1,4 @@
-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";
@@ -6,6 +6,7 @@ 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";
@@ -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);
diff --git a/apps/server/src/services/generator.ts b/apps/server/src/services/generator.ts
index 61c30df..d6dee54 100644
--- a/apps/server/src/services/generator.ts
+++ b/apps/server/src/services/generator.ts
@@ -239,12 +239,17 @@ function extractHtmlFromStdout(stdout: string): string | null {
return html.length > 512 && / {
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));
diff --git a/apps/server/src/services/mailer.ts b/apps/server/src/services/mailer.ts
index 068ccaf..afdb6f3 100644
--- a/apps/server/src/services/mailer.ts
+++ b/apps/server/src/services/mailer.ts
@@ -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 = `
+
+
+
+
${t.bar}
+
${t.title}
+
+
+
${t.intro}
+
"${p.prompt}"
+
${t.outro}
+
+
Words to Website · Activity 3 · ${t.footer}
+
+
+
`;
+ return { subject: t.subject, body };
+}
+
+export async function sendFailureMail(p: {
+ taskId: string;
+ to: string;
+ domainLabel: string;
+ prompt: string;
+ lang: "zh" | "en";
+}): Promise {
+ 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)}`,
+ );
+ }
+}
diff --git a/apps/server/src/services/queue.ts b/apps/server/src/services/queue.ts
index d47d722..a37d6f0 100644
--- a/apps/server/src/services/queue.ts
+++ b/apps/server/src/services/queue.ts
@@ -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" };
@@ -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,
@@ -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 = {
diff --git a/apps/server/src/services/screenshot.ts b/apps/server/src/services/screenshot.ts
index 9cd8f00..e69bc33 100644
--- a/apps/server/src/services/screenshot.ts
+++ b/apps/server/src/services/screenshot.ts
@@ -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 {
diff --git a/apps/web/src/components/FilmCard.vue b/apps/web/src/components/FilmCard.vue
index 1f492f7..5759496 100644
--- a/apps/web/src/components/FilmCard.vue
+++ b/apps/web/src/components/FilmCard.vue
@@ -1,6 +1,7 @@
-
@@ -305,6 +483,11 @@ function onVis() {
font-family:
-apple-system, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
+/**
+ * 两级变换:外层每帧只改 translate3d(合成器平移,不触发重新栅格化),
+ * 内层承载单应形变(低频更新)。旧版把整条 matrix3d 写到同一个元素上,
+ * 每帧都要重新栅格化整张卡——既是功耗大头,也是页面区闪烁的来源。
+ */
.fcard {
position: absolute;
left: 0;
@@ -313,6 +496,23 @@ function onVis() {
transform-origin: 0 0;
will-change: transform;
}
+.fcard-inner {
+ position: absolute;
+ left: -73px; /* 卡片半宽:让卡心落在外层原点 */
+ top: -117px; /* 卡片半高 */
+ width: 146px;
+ height: 234px;
+ transform-origin: 0 0;
+}
+/* 点卡直达:父容器 pointer-events-none,这里单独放行命中 */
+.clickable {
+ pointer-events: auto;
+ cursor: pointer;
+}
+/* 悬停掀帘:--veil 是 rAF 写在外层内联样式上的,需 !important 才能盖过 */
+.clickable:hover {
+ --veil: 0 !important;
+}
.start-link {
position: absolute;
right: 28px;
@@ -338,4 +538,36 @@ function onVis() {
outline: 1.5px dashed rgba(247, 212, 71, 0.75);
outline-offset: 5px;
}
+/* 左下角二维码牌:米白卡 + 黑描边 + 黄硬影,与 START 按钮同一视觉层 */
+.qr-plate {
+ position: absolute;
+ left: 28px;
+ bottom: 28px;
+ z-index: 30;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 16px 10px 10px;
+ background: #fffdf9;
+ border: 2.5px solid #1c1917;
+ border-radius: 12px;
+ box-shadow: 5px 5px 0 rgba(247, 212, 71, 0.85);
+}
+.qr-img {
+ display: block;
+ width: 104px;
+ height: 104px;
+ border-radius: 6px;
+}
+.qr-text {
+ max-width: 330px;
+}
+.qr-main {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 900;
+ line-height: 1.5;
+ color: #1c1917;
+ white-space: pre-line; /* 双行文案用 \n 分行 */
+}
diff --git a/apps/web/src/components/FilmWallPhone.vue b/apps/web/src/components/FilmWallPhone.vue
index 6204bdf..458d1fb 100644
--- a/apps/web/src/components/FilmWallPhone.vue
+++ b/apps/web/src/components/FilmWallPhone.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import FilmCard from "@/components/FilmCard.vue";
import { t } from "@/i18n";
-import type { WallItem } from "@/lib/wall";
+import { openWallItem, type WallItem } from "@/lib/wall";
import {
PERF_TILE,
PCH,
@@ -120,11 +120,15 @@ onUnmounted(() => removeEventListener("resize", fit));
v-for="(it, i) in trackL"
:key="i"
class="pcard"
+ :class="{ clickable: !!it.url }"
+ role="link"
+ :title="it.domain ?? undefined"
:style="{
width: PCW + 'px',
height: PCH + 'px',
marginBottom: PSTRIDE - PCH + 'px',
}"
+ @click="openWallItem(it)"
>
@@ -145,11 +149,15 @@ onUnmounted(() => removeEventListener("resize", fit));
v-for="(it, i) in trackR"
:key="i"
class="pcard"
+ :class="{ clickable: !!it.url }"
+ role="link"
+ :title="it.domain ?? undefined"
:style="{
width: PCW + 'px',
height: PCH + 'px',
marginBottom: PSTRIDE - PCH + 'px',
}"
+ @click="openWallItem(it)"
>
@@ -182,6 +190,10 @@ onUnmounted(() => removeEventListener("resize", fit));
font-family:
-apple-system, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
+/* 点卡直达:触屏无 hover,cursor 只对外接鼠标生效 */
+.pcard.clickable {
+ cursor: pointer;
+}
.start-link {
position: absolute;
left: 16px;
diff --git a/apps/web/src/components/LazyFrame.vue b/apps/web/src/components/LazyFrame.vue
index 1ecdc86..757ff7a 100644
--- a/apps/web/src/components/LazyFrame.vue
+++ b/apps/web/src/components/LazyFrame.vue
@@ -1,10 +1,26 @@
-
+
+import { ref } from "vue";
import { t } from "@/i18n";
/**
* 步骤① 欢迎:主张 + 三行 howto + 开始按钮。
- * 纯展示,点击即向父级抛 next 进入表单步。
+ * 点开始先弹「你会得到什么」伪弹窗(单 HTML 页面的能力边界,
+ * 用户视角措辞),确认后才进入表单步。
*/
-defineEmits<{ next: [] }>();
+const emit = defineEmits<{ next: [] }>();
+const notice = ref(false);
+
+const CAN = () => [t("intro.noticeCan1"), t("intro.noticeCan2")];
+const CANT = () => [
+ t("intro.noticeCant1"),
+ t("intro.noticeCant2"),
+ t("intro.noticeCant3"),
+];
const HOWTO = () =>
[
@@ -26,9 +36,39 @@ const HOWTO = () =>
-
diff --git a/apps/web/src/components/home/SentStep.vue b/apps/web/src/components/home/SentStep.vue
new file mode 100644
index 0000000..6b3dd3b
--- /dev/null
+++ b/apps/web/src/components/home/SentStep.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
{{ t("sent.title") }}
+
{{ t("sent.sub") }}
+
{{ email }}
+
+ {{ t("sent.confirm") }}
+
+
+
+
+
diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts
index af09ada..b7f305a 100644
--- a/apps/web/src/i18n/en.ts
+++ b/apps/web/src/i18n/en.ts
@@ -8,6 +8,7 @@ const dict = {
"home.step.form": "Your Info",
"home.step.waiting": "Generating",
"home.step.done": "Done",
+ "home.step.sent": "Submitted",
"home.stepOf": "Step {n} / {total}",
"home.backToScreen": "Back to the live wall",
@@ -19,6 +20,18 @@ const dict = {
"intro.how2": "The AI builds your page on the spot",
"intro.how3": "Auto-published — the URL goes to your inbox",
"intro.start": "Start",
+ "intro.noticeBar": "BEFORE WE START",
+ "intro.noticeTitle": "About the page you'll get in this event",
+ "intro.noticeLead":
+ "The site we generate is a single HTML page — if you'd like to add more complex logic, it may not be able to deliver that.",
+ "intro.noticeCanTitle": "Can do",
+ "intro.noticeCan1": "Rich text, images and layout",
+ "intro.noticeCan2": "Animations and interactions",
+ "intro.noticeCantTitle": "Cannot",
+ "intro.noticeCant1": "Sign-in and accounts",
+ "intro.noticeCant2": "Storing data, payments or emails",
+ "intro.noticeCant3": "Realtime multi-user features",
+ "intro.noticeOk": "Start describing",
/* ② 填写信息 */
"form.descLabel": "Describe the page you want",
@@ -48,11 +61,17 @@ const dict = {
"waiting.help": "Ask staff for help",
"waiting.errorCode": "Error",
- /* ④ 完成 */
+ /* ④ 完成(手机端) */
"done.title": "Your page is live!",
"done.mailed": "The URL has also been emailed to: {email}",
"done.restart": "Make one for a friend →",
+ /* ③' 已提交(电脑端:提交即释放) */
+ "sent.title": "Submitted — the AI takes it from here",
+ "sent.sub":
+ "Your page and stamp code will be emailed\nto the inbox below shortly — please wait.",
+ "sent.confirm": "Got it — next one, please",
+
/* 凭证卡 */
"cert.urlLabel": "Your page URL",
"cert.stamp": "Show this page to staff for a stamp",
@@ -65,6 +84,10 @@ const dict = {
"screen.onlinePost": " PAGES ONLINE",
"screen.demo": "DEMO ×{n}",
"screen.start": "Start building your page",
+ /* 大屏文字全场共见:中英同显,不随语言切换 */
+ "screen.qrMain":
+ "也可在自己设备上尝试!\nAlternatively, try it on your own device!",
+ "screen.qrAlt": "QR code to the start page",
/* 大屏卡片外壳 */
"film.proof": "PROOF",
diff --git a/apps/web/src/i18n/zh.ts b/apps/web/src/i18n/zh.ts
index 6eafcd6..a869f72 100644
--- a/apps/web/src/i18n/zh.ts
+++ b/apps/web/src/i18n/zh.ts
@@ -10,6 +10,7 @@ const dict: Record = {
"home.step.form": "填写信息",
"home.step.waiting": "生成中",
"home.step.done": "完成",
+ "home.step.sent": "已提交",
"home.stepOf": "步骤 {n} / {total}",
"home.backToScreen": "返回现场大屏",
@@ -20,6 +21,18 @@ const dict: Record = {
"intro.how2": "AI 现场为你生成网页",
"intro.how3": "自动发布,网址发送到你的邮箱",
"intro.start": "开始体验",
+ "intro.noticeBar": "开始之前",
+ "intro.noticeTitle": "关于你可通过本活动得到的网页",
+ "intro.noticeLead":
+ "我们生成的 site 是一个单 HTML 网页;如果你想加一些比较复杂的逻辑,它可能不一定能实现。",
+ "intro.noticeCanTitle": "能做到",
+ "intro.noticeCan1": "图文排版与展示",
+ "intro.noticeCan2": "简单动画与交互效果",
+ "intro.noticeCantTitle": "不能做到",
+ "intro.noticeCant1": "注册登录、账号体系",
+ "intro.noticeCant2": "数据保存、支付、发邮件",
+ "intro.noticeCant3": "多人实时同步",
+ "intro.noticeOk": "开始描述",
/* ② 填写信息 */
"form.descLabel": "描述你想要的网页",
@@ -48,11 +61,16 @@ const dict: Record = {
"waiting.help": "找工作人员帮忙",
"waiting.errorCode": "错误码",
- /* ④ 完成 */
+ /* ④ 完成(手机端) */
"done.title": "网页发布成功!",
"done.mailed": "网址也已发送到你的邮箱:{email}",
"done.restart": "帮朋友也做一个 →",
+ /* ③' 已提交(电脑端:提交即释放) */
+ "sent.title": "提交成功,接下来交给 AI",
+ "sent.sub": "你的网页与凭证稍后会以邮件形式\n发送到下面的邮箱,请耐心等候",
+ "sent.confirm": "了解了,换下一位",
+
/* 凭证卡 */
"cert.urlLabel": "你的网页地址",
"cert.stamp": "凭此页面找工作人员集章",
@@ -65,6 +83,10 @@ const dict: Record = {
"screen.onlinePost": " 个网页",
"screen.demo": "演示模式 ×{n}",
"screen.start": "开始制作你的网页",
+ /* 大屏文字全场共见:中英同显,不随语言切换 */
+ "screen.qrMain":
+ "也可在自己设备上尝试!\nAlternatively, try it on your own device!",
+ "screen.qrAlt": "跳转制作页的二维码",
/* 大屏卡片外壳 */
"film.proof": "凭证",
diff --git a/apps/web/src/lib/captureQueue.ts b/apps/web/src/lib/captureQueue.ts
new file mode 100644
index 0000000..2c174a1
--- /dev/null
+++ b/apps/web/src/lib/captureQueue.ts
@@ -0,0 +1,123 @@
+import { toPng } from "@/lib/vendor/html-to-image.js";
+import { apiUrl } from "@/lib/apiBase";
+import { api, type TaskStatus } from "@/composables/useApi";
+
+/**
+ * 截图守护(模块级单例,与界面步骤完全解耦)。
+ *
+ * 2026-09-20 电脑端改异步后,用户提交完就离开(sent 页点确认即重置),
+ * 不再有任何「步骤」挂着任务——截图不能继续绑在 done 步组件上。
+ * 这里改成:提交成功即 watchCapture(taskId),本模块自己轮询任务状态,
+ * published 后在隐藏 iframe 里渲染产物并截图上传;失败/超时静默放弃,
+ * 大屏自动回退 iframe 预览,绝不影响主流程。
+ *
+ * 旧 ShotCapture.vue 的取景逻辑原样搬入(保真度结论见组件历史注释:
+ * 必须截 ,420×760@2x,等字体与动画稳定)。
+ *
+ * 注意:单例必须放模块级——写在