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 @@ 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 @@ + + + + + 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,等字体与动画稳定)。 + * + * 注意:单例必须放模块级——写在