diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index bd51f23..aaf4c62 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -79,6 +79,7 @@ export interface TaskRow { screenshot: number; created_at: number; finished_at: number | null; + style_hint: string | null; } export interface SessionRow { @@ -101,6 +102,7 @@ export const tasks = { email: string; domain: string; isPublic: boolean; + styleHint: string | null; }) { db.insert(tasksTable) .values({ @@ -113,6 +115,7 @@ export const tasks = { email: p.email, domain: p.domain, is_public: p.isPublic ? 1 : 0, + style_hint: p.styleHint, created_at: Date.now(), }) .run(); @@ -152,20 +155,22 @@ export const tasks = { id: string; code: string | null; domain: string | null; - prompt: string; publish_url: string | null; + prompt: string; screenshot: number; created_at: number; + style_hint: string | null; }> { return db .select({ id: tasksTable.id, code: tasksTable.code, domain: tasksTable.domain, - prompt: tasksTable.prompt, publish_url: tasksTable.publish_url, + prompt: tasksTable.prompt, screenshot: tasksTable.screenshot, created_at: tasksTable.created_at, + style_hint: tasksTable.style_hint, }) .from(tasksTable) .where( diff --git a/apps/server/src/routes/tasks.ts b/apps/server/src/routes/tasks.ts index d668f27..4d1dc41 100644 --- a/apps/server/src/routes/tasks.ts +++ b/apps/server/src/routes/tasks.ts @@ -8,6 +8,7 @@ import { queue } from "../services/queue.js"; import { publish } from "../services/publisher.js"; import { sendCompletionMail } from "../services/mailer.js"; import { captureScreenshot, shotPath } from "../services/screenshot.js"; +import { pickStyle } from "../services/styleHint.js"; import { newTaskId, newCertCode, isValidDeviceId } from "../util/ids.js"; export const tasksRouter = Router(); @@ -79,6 +80,7 @@ tasksRouter.post("/", (req: Request, res: Response) => { email: mail, domain, isPublic: isPublic !== false, + styleHint: pickStyle(trimmed), }); queue.enqueueGen(id); res.json({ taskId: id, queuePosition: queue.positionOf(id), domain }); @@ -123,6 +125,7 @@ screenRouter.get("/all", (_req: Request, res: Response) => { prompt: t.prompt, hasScreenshot: !!t.screenshot, createdAt: t.created_at, + styleHint: t.style_hint, })), ); }); diff --git a/apps/server/src/schema.ts b/apps/server/src/schema.ts index 0d9626c..f4f2c9d 100644 --- a/apps/server/src/schema.ts +++ b/apps/server/src/schema.ts @@ -36,6 +36,16 @@ export const tasksTable = sqliteTable( screenshot: integer("screenshot").default(0), created_at: integer("created_at"), finished_at: integer("finished_at"), + /** + * 大屏卡片外壳(七式之一:archive/fullscreen/spine/bigno/collage/bubble/classic)。 + * 创建任务时由主题关键词分类器一次性算出并写入;前台只读不判。 + * + * 注意:这里**不要**加 .default()。drizzle-kit 对 SQLite 只在「新增可空无默认列」 + * 时才生成 ALTER TABLE ADD COLUMN;一旦带 DEFAULT 就退化为整表重建, + * 而重建序列里的 `INSERT ... SELECT "style_hint" FROM tasks` 会引用尚不存在的新列 + * 直接报错(旧库上服务起不来)。历史行的 NULL 由前端兜底成默认外壳。 + */ + style_hint: text("style_hint"), }, (t) => [ index("idx_tasks_status").on(t.status), diff --git a/apps/server/src/services/styleHint.ts b/apps/server/src/services/styleHint.ts new file mode 100644 index 0000000..aa8e579 --- /dev/null +++ b/apps/server/src/services/styleHint.ts @@ -0,0 +1,1262 @@ +/** + * Words2Site 大屏 · 按内容自动选款 —— 主题关键词库 + 分类器 + * + * 使用位置:后端创建 wall item 时调用一次 classifyTheme(),结果存 styleHint; + * 前端只按 hint 渲染,不做判断。 + * + * 规则: + * 1. 整段用户输入对四个词库做匹配(中文子串匹配;纯英文词按词边界匹配、不区分大小写, + * 避免 "ai" 误命中 "wait" 这类问题)。 + * 2. 命中关键词数最多的主题胜出("我家猫猫的日常" → cute 2 票 > daily 1 票)。 + * 3. 并列 → 并列主题里随机一个(保持墙面多样性,不引入优先级偏见)。 + * 4. 零命中 → 返回 null,由调用方从七种样式随机(兜底)。 + * + * 词库就是纯数据,随时增删;调完建议跑一遍真实输入做回归。 + */ + +export type CardTheme = "tech" | "daily" | "arts" | "cute"; + +export type CardStyle = + | "archive" // 01 档案 + | "fullscreen" // 02 满幕 + | "spine" // 03 书脊 + | "bigno" // 04 巨号 + | "collage" // 05 拼贴 + | "bubble" // 06 泡泡 + | "classic"; // 07 典雅 + +export const ALL_STYLES: CardStyle[] = [ + "archive", + "fullscreen", + "spine", + "bigno", + "collage", + "bubble", + "classic", +]; + +/** 主题 → 样式池(同主题两种随机二选一) */ +export const THEME_STYLES: Record = { + tech: ["fullscreen", "bigno"], // 02 / 04 + daily: ["archive", "collage"], // 01 / 05 + arts: ["spine", "classic"], // 03 / 07 + cute: ["bubble"], // 06 +}; + +export const LEXICON: Record = { + // ———————————————————— 科技(≈230 词)———————————————————— + tech: [ + // 泛科技 / 互联网 + "科技", + "技术", + "高科技", + "未来", + "未来感", + "科幻", + "硬核", + "极客", + "极客风", + "数字化", + "数据", + "智能", + "人工智能", + "机器学习", + "深度学习", + "神经网络", + "大模型", + "算法", + "算力", + "机器人", + "机械", + "自动化", + "芯片", + "处理器", + "半导体", + "电路", + "代码", + "编程", + "程序员", + "软件", + "系统", + "硬件", + "服务器", + "数据库", + "网络", + "互联网", + "物联网", + "蓝牙", + "wifi", + "5g", + "app", + "pc", + "ai", + "加密", + "区块链", + "数字货币", + "比特币", + "元宇宙", + "虚拟现实", + "vr", + "ar", + "数字孪生", + "二进制", + "二维码", + "赛博", + "赛博朋克", + "cyberpunk", + "黑客", + "hacker", + "骇客", + "终端", + "命令行", + "linux", + "windows", + "安卓", + "开发者", + "程序员节", + "开源", + "极客圈", + "机器", + "设备", + // 数码 / 装机 + "手机", + "电脑", + "笔记本", + "台式机", + "显卡", + "主板", + "内存", + "硬盘", + "键盘", + "鼠标", + "显示器", + "屏幕", + "摄像头", + "耳机", + "音箱", + "充电", + "电池", + "无人机", + "智能手表", + "手环", + "智能家居", + "扫地机器人", + "装机", + "diy", + "水冷", + "rgb", + "灯效", + "机箱", + "外设", + "电竞椅", + "网吧", + "网咖", + "数码", + "数码产品", + "测评", + "开箱评测", + "跑分", + // 宇宙 / 航天 + "太空", + "宇宙", + "银河", + "星系", + "星空", + "星辰", + "星际", + "行星", + "恒星", + "彗星", + "流星", + "卫星", + "月球", + "火星", + "金星", + "木星", + "土星", + "黑洞", + "星云", + "超新星", + "引力", + "光速", + "虫洞", + "时空", + "航天", + "航空", + "火箭", + "发射", + "升空", + "空间站", + "天宫", + "神舟", + "登月", + "宇航员", + "航天员", + "天文", + "观测站", + "观星", + "望远镜", + "天文台", + "星象", + "星图", + "外星人", + "ufo", + "飞碟", + "三体", + "流浪地球", + "刘慈欣", + // 游戏 / 电竞 + "游戏", + "电竞", + "电子竞技", + "网游", + "手游", + "端游", + "主机", + "掌机", + "steam", + "switch", + "任天堂", + "索尼", + "xbox", + "ps5", + "ps4", + "3a", + "大作", + "单机", + "联机", + "开黑", + "组队", + "排位", + "排位赛", + "段位", + "上分", + "五杀", + "超神", + "mvp", + "carry", + "带飞", + "大神", + "大佬", + "菜鸟", + "萌新", + "手残", + "副本", + "boss", + "团本", + "刷本", + "打怪", + "练级", + "升级", + "满级", + "装备", + "皮肤", + "抽卡", + "氪金", + "欧皇", + "非酋", + "出货", + "保底", + "ssr", + "转蛋", + "扭蛋", + "开箱", + "战绩", + "战报", + "击杀", + "助攻", + "补刀", + "兵线", + "塔防", + "推塔", + "水晶", + "野怪", + "打野", + "中单", + "上单", + "辅助", + "人头", + "dota", + "lol", + "英雄联盟", + "王者荣耀", + "吃鸡", + "绝地求生", + "和平精英", + "原神", + "崩坏", + "星穹铁道", + "明日方舟", + "米哈游", + "我的世界", + "minecraft", + "mc", + "红石", + "模组", + "mod", + "蛋仔", + "光遇", + "第五人格", + "阴阳师", + "三国杀", + "狼人杀", + "剧本杀", + // 竞技 / 机甲 + "竞技", + "对战", + "比赛", + "联赛", + "锦标赛", + "冠军", + "夺冠", + "战队", + "俱乐部", + "集训", + "教练", + "战术", + "策略", + "复盘", + "走位", + "连招", + "手速", + "反应", + "高达", + "变形金刚", + "机甲", + "机战", + "eva", + "战斗机", + "航母", + "坦克", + "导弹", + "战舰", + "潜艇", + ], + + // ———————————————————— 日常(≈230 词)———————————————————— + daily: [ + // 美食 / 干饭 + "美食", + "好吃", + "好喝", + "美味", + "味道", + "口感", + "干饭", + "干饭人", + "吃货", + "馋", + "解馋", + "今天吃什么", + "吃什么", + "晚饭", + "午饭", + "早餐", + "早饭", + "夜宵", + "宵夜", + "加餐", + "下午茶", + "外卖", + "点外卖", + "下馆子", + "探店", + "网红店", + "苍蝇馆子", + "私房菜", + "家常菜", + "拿手菜", + "妈妈的味道", + "手艺", + "下厨", + "做饭", + "烹饪", + "烘焙", + "烤箱", + "菜谱", + "食谱", + "菜市场", + "买菜", + "食材", + "硬菜", + "大餐", + "聚餐", + "团建", + "火锅", + "麻辣烫", + "香锅", + "烧烤", + "撸串", + "串串", + "炸鸡", + "啤酒", + "小酒", + "微醺", + "咖啡", + "咖啡店", + "咖啡因", + "手冲", + "拿铁", + "美式", + "奶茶", + "柠檬茶", + "果茶", + "米线", + "饺子", + "包子", + "馒头", + "家乡菜", + "饭搭子", + "小吃", + // 旅行 / 出行 + "旅行", + "旅游", + "出游", + "出行", + "自由行", + "跟团", + "背包客", + "穷游", + "特种兵", + "景点", + "景区", + "风景", + "名胜", + "古迹", + "爬山", + "登山", + "徒步", + "露营", + "野营", + "野餐", + "帐篷", + "天幕", + "海边", + "看海", + "海岛", + "沙滩", + "赶海", + "潜水", + "浮潜", + "冲浪", + "滑雪", + "漂流", + "骑行", + "自驾", + "自驾游", + "公路旅行", + "川藏", + "318", + "西藏", + "新疆", + "云南", + "大理", + "丽江", + "成都", + "重庆", + "西安", + "长沙", + "淄博", + "哈尔滨", + "杭州", + "苏州", + "citywalk", + "逛街", + "步行街", + "夜市", + "小吃街", + "机票", + "火车", + "高铁", + "民宿", + "酒店", + "青旅", + "行李箱", + "行程", + "攻略", + "游记", + "旅拍", + "特产", + "伴手礼", + "环游", + "说走就走", + "世界那么大", + "想去看看", + // 校园 / 学习 + "校园", + "大学", + "大学生", + "学霸", + "学渣", + "上课", + "下课", + "课间", + "早八", + "晚自习", + "自习", + "图书馆", + "占座", + "期末", + "期末周", + "复习", + "预习", + "课业", + "作业", + "论文", + "答辩", + "毕业", + "毕业季", + "毕业照", + "学分", + "绩点", + "奖学金", + "社团", + "招新", + "学生会", + "班级", + "同学", + "室友", + "舍友", + "宿舍", + "食堂", + "校门", + "军训", + "开学", + "放假", + "寒假", + "暑假", + "假期", + "返校", + "春游", + "秋游", + "运动会", + "球赛", + "篮球", + "足球", + "羽毛球", + "乒乓球", + "网球", + "跑步", + "健身", + "夜跑", + "打球", + "学习搭子", + // 打工 / 生活 + "上班", + "下班", + "打卡", + "加班", + "通勤", + "地铁", + "公交", + "早高峰", + "晚高峰", + "打工人", + "社畜", + "摸鱼", + "划水", + "搬砖", + "上岸", + "考研", + "考公", + "考证", + "面试", + "简历", + "offer", + "hr", + "入职", + "离职", + "跳槽", + "工位", + "工资", + "月薪", + "存钱", + "省钱", + "记账", + "双十一", + "618", + "购物", + "买买买", + "快递", + "拆快递", + "网购", + "收纳", + "断舍离", + "打扫", + "大扫除", + "房间", + "卧室", + "出租屋", + "合租", + "租房", + "搬家", + "水电费", + "生活费", + "月光", + // 心情 / 记录 + "日常", + "记录", + "记录生活", + "生活碎片", + "生命日记", + "日记", + "随手记", + "手帐", + "平淡", + "安稳", + "踏实", + "小确幸", + "松弛感", + "松弛", + "解压", + "摆烂", + "躺平", + "内卷", + "emo", + "精神状态", + "段子", + "搞笑", + "沙雕", + "快乐源泉", + "心情", + "清单", + "愿望清单", + "计划", + "目标", + "自律", + "早起", + "早睡", + "熬夜", + "仪式感", + "烟火气", + "人间烟火", + "好好生活", + "热爱生活", + "万事胜意", + "平平安安", + "加油", + "逆袭", + "成长", + "感悟", + ], + + // ———————————————————— 文艺(≈230 词)———————————————————— + arts: [ + // 文字 / 书香 + "文学", + "读书", + "阅读", + "书单", + "书摘", + "摘抄", + "好句", + "金句", + "摘录", + "书评", + "书架", + "藏书", + "书房", + "诗句", + "诗歌", + "写诗", + "诗人", + "散文", + "随笔", + "杂文", + "小说", + "文集", + "文字", + "文案", + "短句", + "三行情书", + "书信", + "信件", + "手写信", + "笔友", + "钢笔", + "墨水", + "字帖", + "练字", + "书法", + "硬笔", + "毛笔", + "小楷", + "楷书", + "行书", + "草书", + "篆书", + "笔墨", + "宣纸", + "文房四宝", + "水墨", + "国画", + "山水画", + "卷轴", + "字画", + "落款", + "题字", + "文人", + "风骨", + // 古风 / 意象 + "意境", + "留白", + "东方美学", + "中式", + "新中式", + "古典", + "古风", + "国风", + "汉服", + "马面裙", + "旗袍", + "盘扣", + "折扇", + "团扇", + "油纸伞", + "灯笼", + "禅意", + "韵味", + "雅致", + "清雅", + "诗意", + "唐诗", + "宋词", + "楚辞", + "诗经", + "论语", + "国学", + "传统文化", + "名著", + "红楼梦", + "四大名著", + "李白", + "杜甫", + "苏轼", + "李清照", + "词牌", + "平仄", + "对仗", + "押韵", + "绝句", + "律诗", + "竹林", + "孤舟", + "烟雨", + "长亭", + "檐下", + "青瓦", + "白墙", + "小桥", + "流水人家", + "灯火阑珊", + "岁月静好", + "蒹葭", + "秋水", + "一蓑烟雨", + // 摄影 / 影像 + "摄影", + "拍照", + "照片", + "相册", + "相机", + "单反", + "微单", + "胶片", + "胶卷", + "拍立得", + "一次成像", + "显影", + "冲洗", + "暗房", + "底片", + "相纸", + "构图", + "光影", + "曝光", + "快门", + "光圈", + "感光度", + "镜头", + "定焦", + "变焦", + "取景", + "扫街", + "街拍", + "人像", + "风光照", + "出片", + "大片", + "修图", + "调色", + "色调", + "滤镜", + "画册", + "影集", + "照相馆", + "老照片", + "旧照片", + "影像", + "视觉", + // 音乐 + "音乐", + "听歌", + "歌单", + "歌手", + "歌曲", + "单曲循环", + "副歌", + "前奏", + "间奏", + "旋律", + "音符", + "五线谱", + "乐谱", + "简谱", + "钢琴", + "吉他", + "尤克里里", + "小提琴", + "大提琴", + "架子鼓", + "乐器", + "弹唱", + "演奏", + "弹琴", + "练琴", + "音乐会", + "演唱会", + "音乐节", + "livehouse", + "民谣", + "摇滚", + "爵士", + "蓝调", + "布鲁斯", + "说唱", + "嘻哈", + "古典音乐", + "交响乐", + "管弦乐", + "唱片", + "黑胶", + "磁带", + "cd", + "随身听", + "歌词", + "写歌", + "作曲", + "编曲", + "填词", + "乐队", + "排练", + "主唱", + "贝斯", + "鼓手", + "和弦", + "指弹", + "节奏", + // 电影 / 氛围 + "电影", + "电影感", + "影评", + "观影", + "影单", + "电影院", + "老电影", + "黑白片", + "默片", + "胶片感", + "画面感", + "剧本", + "台词", + "独白", + "旁白", + "蒙太奇", + "长镜头", + "慢镜头", + "慢生活", + "慢节奏", + "文艺", + "文艺片", + "小清新", + "清新", + "忧郁", + "忧伤", + "淡淡的", + "灵魂", + "孤独", + "寂寞", + "深夜", + "凌晨", + "午后", + "月色", + "月光", + "晚风", + "晚霞", + "落日", + "夕阳", + "黄昏", + "黎明", + "清晨", + "细雨", + "梅雨", + "雨天", + "雨声", + "初雪", + "秋意", + "梧桐", + "银杏", + "枫叶", + "蒲公英", + "回忆", + "回忆录", + "往事", + "流年", + "光阴", + "岁月", + "时光", + "怀旧", + "复古感", + "博物馆", + "美术馆", + "画展", + "展览", + "手绘", + "素描", + "水彩", + "油画", + "插画", + "艺术感", + "设计感", + "美学", + "侘寂", + "极简主义", + ], + + // ———————————————————— 可爱(≈210 词)———————————————————— + cute: [ + // 泛可爱 + "可爱", + "萌萌", + "萌萌哒", + "萌妹", + "萌娃", + "好萌", + "太萌", + "超萌", + "可可爱爱", + "无敌可爱", + "软萌", + "呆萌", + "奶凶", + "奶呼呼", + "奶味", + "幼稚", + "童心", + "童趣", + "童真", + "少女心", + "少女感", + "粉色", + "粉粉的", + "粉嫩", + "粉色系", + "甜甜的", + "甜妹", + "甜心", + "甜酷", + "元气", + "元气满满", + "活力", + "活泼", + "开朗", + "治愈系", + "q弹", + "q萌", + "圆润", + "圆滚滚", + "胖乎乎", + "软乎乎", + "软软", + "软软的", + "软fufu", + "奶fufu", + "毛茸茸", + "毛绒", + "绒毛", + "乖巧", + "娇小", + "迷你", + "小小只", + "小小的", + "毛球", + // 玩具 / 谷圈 + "玩偶", + "公仔", + "娃娃", + "洋娃娃", + "布偶", + "抱枕", + "挂件", + "痛包", + "吧唧", + "谷子", + "吃谷", + "手办", + "景品", + "盲盒", + "搪胶", + "娃圈", + "棉花娃娃", + "bjd", + "小裙子", + "jk", + "lolita", + "洛丽塔", + "软妹", + "cos", + "cosplay", + "本命", + "小卡", + "生写", + "周边", + "打call", + "应援", + "咕卡", + "贴纸", + // 宠物 + "猫", + "猫咪", + "猫猫", + "小猫", + "猫奴", + "铲屎官", + "吸猫", + "撸猫", + "撸狗", + "猫粮", + "罐头", + "冻干", + "猫条", + "猫窝", + "猫爬架", + "逗猫棒", + "猫砂", + "喵星人", + "喵喵", + "狗狗", + "狗子", + "小狗", + "修狗", + "修猫", + "汪星人", + "遛狗", + "宠物", + "毛孩子", + "主子", + "布偶猫", + "英短", + "美短", + "橘猫", + "三花", + "狸花", + "奶牛猫", + "银渐层", + "金渐层", + "无毛猫", + "柯基", + "哈士奇", + "二哈", + "柴犬", + "边牧", + "金毛", + "泰迪", + "博美", + "比熊", + "萨摩耶", + "兔兔", + "仓鼠", + "金丝熊", + "荷兰猪", + "刺猬", + "乌龟", + "鹦鹉", + "水獭", + "小熊猫", + "大熊猫", + "呼噜", + "踩奶", + "猫爪", + "肉垫", + "洗澡澡", + "抱抱", + "举高高", + "猫咪图鉴", + // 动漫 / 二次元 + "动漫", + "二次元", + "番剧", + "追番", + "补番", + "新番", + "动画", + "动画片", + "漫画", + "条漫", + "四格", + "表情包", + "颜文字", + "壁纸", + "头像", + "情侣头像", + "纸片人", + "异世界", + "魔法少女", + "小魔女", + "精灵", + "独角兽", + "小仙女", + "仙女", + "公主", + "王子", + "城堡", + "童话", + "安徒生", + "二刺猿", + "魔法", + // 生日 / 派对 + "生日", + "生日快乐", + "小寿星", + "过生日", + "许愿", + "蜡烛", + "吹蜡烛", + "生日蛋糕", + "派对", + "聚会", + "庆祝", + "庆生", + "惊喜", + "礼物", + "礼盒", + "蝴蝶结", + "丝带", + "气球", + "彩带", + "花束", + "捧花", + "闺蜜", + "姐妹", + "好朋友", + "友谊", + "友情", + "干杯", + "抱抱熊", + // 甜品 / 零食 + "甜品", + "甜点", + "布丁", + "果冻", + "奶油", + "泡芙", + "蛋挞", + "麻薯", + "大福", + "团子", + "糖葫芦", + "马卡龙", + "舒芙蕾", + "提拉米苏", + "芝士", + "慕斯", + "雪媚娘", + "铜锣烧", + "草莓", + "草莓味", + "樱桃", + "水蜜桃", + "桃子", + "香芋", + "芋泥", + "棉花糖", + "棒棒糖", + "糖果", + "巧克力", + "曲奇", + "雪糕", + "冰淇淋", + "冰棒", + "奶昔", + "果汁", + "汽水", + "泡泡", + "面包", + "吐司", + "贝果", + "可颂", + "甜甜圈", + ], +}; + +/* ———————————————— 匹配与分类 ———————————————— */ + +const ASCII_ONLY = /^[\x21-\x7e]+$/; +const regexCache = new Map(); + +/** 纯英文/数字词 → 词边界正则(不区分大小写);中文词 → null(走 includes) */ +function toRegex(word: string): RegExp | null { + if (!ASCII_ONLY.test(word)) return null; + if (!regexCache.has(word)) { + const body = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + regexCache.set(word, new RegExp(`(^|[^a-z0-9])${body}([^a-z0-9]|$)`, "i")); + } + return regexCache.get(word) ?? null; +} + +function countHits(lowerText: string, words: string[]): number { + let n = 0; + for (const w of words) { + const r = toRegex(w); + if (r ? r.test(lowerText) : lowerText.includes(w)) n++; + } + return n; +} + +const THEMES = Object.keys(LEXICON) as CardTheme[]; + +/** 命中最多关键词的主题胜出;并列随机;零命中返回 null(兜底随机样式) */ +export function classifyTheme(text: string): CardTheme | null { + const lower = text.toLowerCase(); + const scores = THEMES.map((t) => [t, countHits(lower, LEXICON[t])] as const); + const max = Math.max(...scores.map(([, n]) => n)); + if (max === 0) return null; + const tied = scores.filter(([, n]) => n === max).map(([t]) => t); + if (tied.length === 1) return tied[0]; + return tied[Math.floor(Math.random() * tied.length)]; +} + +/** 调试用:返回各主题票数与胜出主题 */ +export function classifyDetail(text: string): { + scores: Record; + theme: CardTheme | null; +} { + const lower = text.toLowerCase(); + const scores = Object.fromEntries( + THEMES.map((t) => [t, countHits(lower, LEXICON[t])]), + ) as Record; + const max = Math.max(...Object.values(scores)); + if (max === 0) return { scores, theme: null }; + const tied = THEMES.filter((t) => scores[t] === max); + const theme = + tied.length === 1 ? tied[0] : tied[Math.floor(Math.random() * tied.length)]; + return { scores, theme }; +} + +/** 一步到位:输入 → 样式 id(未命中 → 七种随机) */ +export function pickStyle(text: string): CardStyle { + const theme = classifyTheme(text); + if (!theme) return ALL_STYLES[Math.floor(Math.random() * ALL_STYLES.length)]; + const pool = THEME_STYLES[theme]; + return pool[Math.floor(Math.random() * pool.length)]; +} diff --git a/apps/web/src/components/CertificateCard.vue b/apps/web/src/components/CertificateCard.vue index 680ec60..3639456 100644 --- a/apps/web/src/components/CertificateCard.vue +++ b/apps/web/src/components/CertificateCard.vue @@ -55,49 +55,53 @@ const isRelative = computed( diff --git a/apps/web/src/components/FilmCard.vue b/apps/web/src/components/FilmCard.vue index 0664ce3..a3d94f7 100644 --- a/apps/web/src/components/FilmCard.vue +++ b/apps/web/src/components/FilmCard.vue @@ -2,11 +2,18 @@ import LazyFrame from "@/components/LazyFrame.vue"; import { demoPage } from "@/lib/demoPages"; import { apiUrl } from "@/lib/apiBase"; +import { DEFAULT_STYLE, isCardStyle, type CardStyle } from "@/lib/styleHint"; /** * 胶卷上的「一格底片」:页面预览 + 卡脚(门牌域名 + 凭证章)。 * 尺寸由父级给定(桌面 146×234、手机 ≈118×189),内容按比例缩放; * 远端压暗由父层通过 CSS 变量 --veil 控制(0=全亮)。 + * + * styleHint 决定渲染哪种外壳(七式之一);缺省 archive 米纸衬底。 + * LazyFrame(真实生成页)始终渲染在每个外壳的「页面区」里。 + * + * 几何值全部对着定稿视觉稿(Desktop/CPU/大屏卡片样式七种_带文字.png) + * 量出来的百分比走,改动前先量再改(见 skill words2site-dev)。 */ const props = withDefaults( defineProps<{ @@ -16,71 +23,592 @@ const props = withDefaults( url: string | null; hasScreenshot: boolean; demoIndex?: number; + styleHint?: CardStyle | null; w: number; h: number; }>(), - { demoIndex: undefined }, + { demoIndex: undefined, styleHint: null }, ); -const footH = props.h * 0.145; // 卡脚高度 ≈ 桌面 34 / 手机 27 -const fs = props.w / 146; // 相对桌面卡的比例,用于字号缩放 +/** + * 外壳取值:只认七个合法字面量。 + * 旧记录可能是 null,迁移时补的默认值可能是空串 —— 都不能直接当样式用, + * 否则会落进最后的兜底模板(无外壳),看起来和"没做这个功能"一样。 + */ +const style: CardStyle = isCardStyle(props.styleHint) + ? props.styleHint + : DEFAULT_STYLE; + +/** 内嵌的 LazyFrame 选用哪种源(演示 srcdoc / 真实截图 / 真实 HTML) */ +const frameSrc = + props.demoIndex !== undefined + ? { srcdoc: demoPage(props.demoIndex ?? 0) } + : props.hasScreenshot + ? { src: apiUrl(`/api/tasks/${props.taskId}/screenshot`) } + : { src: apiUrl(`/api/tasks/${props.taskId}/html`) }; + +const cardTitle = props.domain ?? props.taskId; + +/** + * em 基准:外壳里的零碎(标头 NO.、域名、凭证章、巨号…)都写成 em, + * 需要随卡片尺寸等比缩放。146px 宽的底片上取 12.5px —— 于是 + * 0.5em≈6.3px(域名)、0.78em≈9.8px(NO.)、4em≈50px(巨号)都落在可读区间; + * 同时 0.6em 的内边距≈7.5px,页面预览才真正和卡边留出呼吸。 + * (旧值 `w / 146` 让基准恒等于 1px,所有 em 尺寸塌成亚像素、渲染成空白小方块。) + */ +const emBase = (props.w / 146) * 12.5; @@ -261,4 +272,13 @@ onUnmounted(() => { transform-origin: 0 0; will-change: transform; } +.start-link { + position: absolute; + z-index: 30; + border-radius: 10px; +} +.start-link:hover { + outline: 1.5px dashed rgba(247, 212, 71, 0.75); + outline-offset: 5px; +} diff --git a/apps/web/src/components/FilmWallPhone.vue b/apps/web/src/components/FilmWallPhone.vue index c3d5dea..faba4e2 100644 --- a/apps/web/src/components/FilmWallPhone.vue +++ b/apps/web/src/components/FilmWallPhone.vue @@ -11,6 +11,7 @@ import { PHONE, PRIGHT_Y, PSTRIDE, + PHONE_SPEED, bgPhone, footPhone, headPhone, @@ -53,9 +54,9 @@ const trackR = computed(() => ); const perfTile = PERF_TILE; -/** 走完一个卡步长 / 一个齿孔步长的时间(s),两者同速 */ -const DUR = PSTRIDE / 26; -const HOLE_DUR = PHONE.PPERF / 26; +/** 走完一个卡步长 / 一个齿孔步长的时间(s),两者同速——速度统一在 filmRibbon.ts 的 PHONE_SPEED 改 */ +const DUR = PSTRIDE / PHONE_SPEED; +const HOLE_DUR = PHONE.PPERF / PHONE_SPEED; const perfStyle = { width: PHW * 2 + "px", @@ -154,10 +155,23 @@ onUnmounted(() => removeEventListener("resize", fit)); :height="H" :viewBox="`0 0 ${W} ${H}`" v-html=" - `` + - `WAITING FOR THE FIRST PAGE…` + `` + + `` + + `WAITING FOR THE FIRST PAGE…` " /> + + @@ -167,6 +181,11 @@ onUnmounted(() => removeEventListener("resize", fit)); font-family: -apple-system, "PingFang SC", "Microsoft YaHei", Arial, sans-serif; } +.start-link { + position: absolute; + z-index: 30; + border-radius: 8px; +} .clip { position: absolute; left: 0; diff --git a/apps/web/src/components/PreviewFrame.vue b/apps/web/src/components/PreviewFrame.vue index 678e8c4..84848fd 100644 --- a/apps/web/src/components/PreviewFrame.vue +++ b/apps/web/src/components/PreviewFrame.vue @@ -22,17 +22,28 @@ const src = computed(() => diff --git a/apps/web/src/components/RecorderPanel.vue b/apps/web/src/components/RecorderPanel.vue index 329d027..91e443c 100644 --- a/apps/web/src/components/RecorderPanel.vue +++ b/apps/web/src/components/RecorderPanel.vue @@ -2,7 +2,6 @@ import { ref } from "vue"; import { useRecorder } from "@/composables/useRecorder"; import { apiUrl } from "@/lib/apiBase"; -import Badge from "@/components/ui/Badge.vue"; import { Mic, Square } from "lucide-vue-next"; const emit = defineEmits<{ @@ -70,7 +69,7 @@ const recording = rec.state; >录音中 {{ rec.elapsed.value }}s / 90s - @@ -82,8 +81,7 @@ const recording = rec.state;

{{ uploadError }},请打字输入

- 提示 - 想想你要什么网页:介绍你的猫?你的社团?你最喜欢的球队? + 想想你要什么网页:介绍你的猫?你的社团?你最喜欢的球队?
说得越具体,生成的网页越精彩。
@@ -91,17 +89,17 @@ const recording = rec.state; diff --git a/apps/web/src/lib/demoPages.ts b/apps/web/src/lib/demoPages.ts index 07cc8bf..82d9593 100644 --- a/apps/web/src/lib/demoPages.ts +++ b/apps/web/src/lib/demoPages.ts @@ -1,8 +1,26 @@ /** - * 大屏演示页生成器:/screen?demo=N 时填充 N 张模拟卡片(srcdoc), + * 大屏演示页生成器:/?demo=N 时填充 N 张模拟卡片(srcdoc), * 用于压测滚动墙与活动前大屏预演,不依赖服务端数据。 * 产物为自包含 HTML(无外链、无 emoji),按模板参数生成多样化版式。 + * + * 【重要】用户只输入文字,真实生成页里不存在自定义图片。 + * 所以这里不设任何图片占位(灰块 / 色块 / 占位条), + * 页面 = 纯文字排版 + 一枚统一预设图案(见 ART)。 */ +import type { CardStyle } from "./styleHint"; + +/** 统一预设图案(形状固定,颜色跟随该页主题色) */ +type ArtKey = + | "cup" + | "orbit" + | "moon" + | "ring" + | "star" + | "bars" + | "wave" + | "grid" + | "steam" + | "glyph"; interface DemoSpec { title: string; @@ -12,6 +30,14 @@ interface DemoSpec { ink: string; // 前景色 accent: string; // 强调色 layout: "hero" | "cards" | "split" | "stripes"; + /** 预设图案 */ + art: ArtKey; + /** 真实文案行(替代原来的占位灰条) */ + lines: string[]; + /** art = glyph 时显示的方块字 */ + glyph?: string; + /** 真实分类跑出来偏中性,硬编码更稳定;按 SPEC 视觉特征挑 */ + styleHint: CardStyle; } const SPECS: DemoSpec[] = [ @@ -23,6 +49,9 @@ const SPECS: DemoSpec[] = [ ink: "#FFFFFF", accent: "#FFE3EE", layout: "cards", + art: "star", + lines: ["三花 · 布偶 · 橘猫", "领养日记 每日更新", "相册与健康档案"], + styleHint: "bubble", // 06 泡泡:可爱主题 }, { title: "深空观测站", @@ -32,6 +61,9 @@ const SPECS: DemoSpec[] = [ ink: "#E7ECFF", accent: "#8FA6FF", layout: "hero", + art: "orbit", + lines: ["22:40 木星合月", "23:10 英仙座流星雨", "00:30 深空摄影"], + styleHint: "fullscreen", // 02 满幕:深色铺满 }, { title: "手冲咖啡笔记", @@ -41,6 +73,13 @@ const SPECS: DemoSpec[] = [ ink: "#F3E7D8", accent: "#D8A25E", layout: "stripes", + art: "cup", + lines: [ + "15g 粉 · 1:16 · 92°C", + "闷蒸 30 秒后三段注水", + "总时长 2 分 30 秒", + ], + styleHint: "archive", // 01 档案:米纸衬底 }, { title: "水墨江南", @@ -50,6 +89,9 @@ const SPECS: DemoSpec[] = [ ink: "#2B2B26", accent: "#8C3B2E", layout: "split", + art: "moon", + lines: ["乌镇 · 西塘 · 南浔", "雨天走青石板路", "三天两夜慢行"], + styleHint: "spine", // 03 书脊:文艺 / 档案标签感 }, { title: "节奏实验室", @@ -59,6 +101,13 @@ const SPECS: DemoSpec[] = [ ink: "#F4E9FF", accent: "#FF5ED2", layout: "stripes", + art: "bars", + lines: [ + "吉他 张野 · 贝斯 阿哲", + "鼓 小满 · 键盘 丁丁", + "每周四 19:00 排练", + ], + styleHint: "bigno", // 04 巨号:出血大序号 }, { title: "火锅研究所", @@ -68,6 +117,9 @@ const SPECS: DemoSpec[] = [ ink: "#FFF2E8", accent: "#FFC53D", layout: "cards", + art: "ring", + lines: ["牛油锅底 32 味", "麻辣度 8.5 / 10", "人均 78 元"], + styleHint: "collage", // 05 拼贴:手作感 }, { title: "绿茵战报", @@ -77,6 +129,9 @@ const SPECS: DemoSpec[] = [ ink: "#E6F5EC", accent: "#57D9A3", layout: "split", + art: "grid", + lines: ["近五轮 3 胜 1 平", "主场连续 7 场不败", "下轮 客战 理工学院"], + styleHint: "collage", // 05 拼贴 }, { title: "生日快乐", @@ -86,6 +141,10 @@ const SPECS: DemoSpec[] = [ ink: "#5A2A1A", accent: "#FFFFFF", layout: "hero", + art: "glyph", + glyph: "乐", + lines: ["九月的第三个周六", "学生活动中心 2F", "带上你的祝福就好"], + styleHint: "bubble", // 06 泡泡 }, { title: "海边邮局", @@ -95,6 +154,10 @@ const SPECS: DemoSpec[] = [ ink: "#FFFFFF", accent: "#FFE9B8", layout: "cards", + art: "glyph", + glyph: "拾", + lines: ["慢递 · 一年后寄出", "手写卡片 免费领取", "盖当日灯塔邮戳"], + styleHint: "classic", // 07 典雅:博物馆展签 }, { title: "旧书地下室", @@ -104,6 +167,10 @@ const SPECS: DemoSpec[] = [ ink: "#EFE9DC", accent: "#C9B458", layout: "stripes", + art: "glyph", + glyph: "书", + lines: ["一本换一本 不收押金", "每周三 18:00 开箱", "已流通 216 册"], + styleHint: "spine", // 03 书脊 }, { title: "水族馆夜场", @@ -113,6 +180,9 @@ const SPECS: DemoSpec[] = [ ink: "#DFF6FF", accent: "#4FD8EB", layout: "hero", + art: "wave", + lines: ["发光水母特展", "深海走廊 20:30 开放", "夜场票 58 元"], + styleHint: "fullscreen", // 02 满幕 }, { title: "拉面地图", @@ -122,9 +192,20 @@ const SPECS: DemoSpec[] = [ ink: "#FFF6EC", accent: "#3B2415", layout: "split", + art: "steam", + lines: ["豚骨 · 酱油 · 味噌", "已打卡 12 家", "汤头浓度评分榜"], + styleHint: "collage", // 05 拼贴 }, ]; +/** + * 基准设计宽度:demo 页按 390px 移动页设计(下面所有数值原本都是该宽度下的 px)。 + * 卡片里的 iframe 只有 120–145px 宽,直接沿用 px 会让标题、间距整体溢出被裁 —— + * 所以统一改写成 vw(相对 iframe 宽度),任意卡片尺寸下都能等比落在框内。 + */ +const BASE_W = 390; +const v = (px: number): string => `${+(px / (BASE_W / 100)).toFixed(3)}vw`; + function esc(s: string): string { return s.replace( /[&<>"]/g, @@ -132,71 +213,126 @@ function esc(s: string): string { ); } -function body(spec: DemoSpec): string { - const tags = spec.tags - .map( - (t) => - `${esc(t)}`, +const svg = (inner: string): string => + `${inner}`; + +/** + * 统一图案库:全部为纯矢量描边/填充图形(不是图片、不是占位框)。 + * 颜色跟随该页主题色,glyph 用品牌黄黑以保证可读。 + */ +const ART: Record string> = { + // 咖啡杯 + cup: (s) => + svg(` + + + + `), + // 深空星图:同心圆 + 准星 + 点阵 + orbit: (s) => + svg(` + + + + + + + + + `), + // 大圆 + 小圆(水墨留白) + moon: (s) => + svg(` + `), + // 白描圆环 + ring: (s) => + svg( + ``, + ), + // 五角星 + star: (s) => + svg( + ``, + ), + // 均衡器音轨 + bars: (s) => + svg(` + + + + + + `), + // 水波 + wave: (s) => + svg(` + + + + `), + // 点阵 + grid: (s) => + svg(` + ${Array.from({ length: 5 }, (_, r) => + Array.from( + { length: 5 }, + (_, c) => + ``, + ).join(""), + ).join("")} + `), + // 蒸汽 + steam: (s) => + svg(` + + + + `), + // 方块字:品牌黄底 + 近黑字,全站统一 + glyph: (s) => + `
+
+ ${esc(s.glyph ?? "字")} +
+
`, +}; + +/** 真实文字行(替代原先的占位灰条) */ +function textRows(spec: DemoSpec, style: "plain" | "blocked"): string { + const lines = style === "blocked" ? spec.lines : spec.lines.slice(0, 2); + const rows = lines + .map((l) => + style === "blocked" + ? `
${esc(l)}
` + : `
${esc(l)}
`, ) - .join(" "); - switch (spec.layout) { - case "hero": - return ` -
-
-
-
-
${esc(spec.title)}
-
${esc(spec.subtitle)}
-
${tags}
-
-
`; - case "cards": - return ` -
-
${esc(spec.title)}
-
${esc(spec.subtitle)}
-
- ${Array.from( - { length: 4 }, - (_, i) => ` -
-
-
栏目 ${i + 1}
-
`, - ).join("")} -
-
${tags}
-
`; - case "split": - return ` -
-
-
${esc(spec.title)}
-
-
-
${esc(spec.subtitle)}
- ${Array.from({ length: 3 }, () => `
`).join("")} -
${tags}
-
-
`; - case "stripes": - default: - return ` -
-
${esc(spec.title)}
LIVE
-
${esc(spec.subtitle)}
- ${Array.from( - { length: 3 }, - (_, i) => ` -
-
记录 0${i + 1}
-
———————— ————
-
`, - ).join("")} -
${tags}
-
`; - } + .join(""); + return `
${rows}
`; +} + +/** + * 页面正文:居中构图(图案 → 标题 → 副标题 → 短强调线 → 文字行 → 主题词)。 + * 用户只输入文字,生成页里不存在自定义图片,因此不放任何图片占位; + * 图案一律取自上面的 ART 统一图案库。内容垂直居中铺满, + * 避免下半部空出一块(那会看着像"待填图片位")。 + */ +function body(spec: DemoSpec): string { + const artW: Record = { + hero: 30, + split: 27, + cards: 24, + stripes: 24, + }; + const blocked = spec.layout === "cards"; + return ` +
+
${ART[spec.art](spec)}
+
${esc(spec.title)}
+
${esc(spec.subtitle)}
+
+ ${textRows(spec, blocked ? "blocked" : "plain")} +
${esc(spec.tags.join(" · "))}
+
`; } export function demoPage(index: number): string { @@ -205,8 +341,9 @@ export function demoPage(index: number): string {
WORDS2SITEDEMO-${String(index + 1).padStart(2, "0")}
@@ -224,6 +361,7 @@ export function demoItems(count: number): Array<{ hasScreenshot: false; createdAt: number; demoIndex: number; + styleHint: CardStyle; }> { const names = [ "cat", @@ -250,6 +388,7 @@ export function demoItems(count: number): Array<{ hasScreenshot: false as const, createdAt: Date.now() - (count - i) * 60_000, demoIndex: i, + styleHint: SPECS[k].styleHint, }; }); } diff --git a/apps/web/src/lib/filmRibbon.ts b/apps/web/src/lib/filmRibbon.ts index 0155930..43deffb 100644 --- a/apps/web/src/lib/filmRibbon.ts +++ b/apps/web/src/lib/filmRibbon.ts @@ -17,8 +17,8 @@ export const PERF = 30; // 齿孔步长(局部) export const CARD_W = 146; // 卡宽(局部,x ∈ [-73, 73]) export const CARD_H = 234; // 卡高(局部,y ∈ [-117, 117]) export const A0 = STRIDE * 0.52; // 卡片格点相位 -export const SPEED = 24; // 桌面走带速度(局部单位/秒) -export const PHONE_SPEED = 26; // 手机走带速度(px/秒) +export const SPEED = 7; // 桌面走带速度(局部单位/秒)—— Ackry 两轮反馈后定值 +export const PHONE_SPEED = 7.5; // 手机走带速度(px/秒) export const Y = "#F7D447"; export const K = "#1C1917"; @@ -490,9 +490,10 @@ export function headDesk(liveCount: number, demoCount: number): string { } /** 右下角厂牌锁版 */ -export function footDesk(): string { +export function footDesk(count: number): string { const W = 1920; - let s = `12 EXPOSURES · 35MM · LOOP`; + const shots = String(Math.max(0, count)).padStart(2, "0"); + let s = `${shots} EXPOSURES · 35MM · LOOP`; s += mark(1470, 972, 84); s += `Computer Psycho Union`; s += `The University of Nottingham Ningbo China`; diff --git a/apps/web/src/lib/styleHint.ts b/apps/web/src/lib/styleHint.ts new file mode 100644 index 0000000..2c597bf --- /dev/null +++ b/apps/web/src/lib/styleHint.ts @@ -0,0 +1,32 @@ +/** + * 大屏卡片外壳(七式之一)。后端用同一个字符串存到 tasks.style_hint, + * 前端只读不判,按字符串分支渲染不同外壳。 + * + * 注:分类词库**不要**放这里 —— 那是服务端的事。前端不需要 200 行关键词。 + * 服务端同名定义见 apps/server/src/services/styleHint.ts。 + */ +export type CardStyle = + | "archive" // 01 档案(米纸衬底+黑标头) + | "fullscreen" // 02 满幕(页面铺满+黑信息条+取景角标) + | "spine" // 03 书脊(黑脊柱竖排域名) + | "bigno" // 04 巨号(出血大序号+黄框取景窗) + | "collage" // 05 拼贴(斜贴米纸+黄胶带) + | "bubble" // 06 泡泡(大圆角+黄虚线) + | "classic"; // 07 典雅(细线双框+衬线) + +export const ALL_STYLES: CardStyle[] = [ + "archive", + "fullscreen", + "spine", + "bigno", + "collage", + "bubble", + "classic", +]; + +/** 缺省样式:未拿到 styleHint(旧记录 / 演示 fallback) */ +export const DEFAULT_STYLE: CardStyle = "archive"; + +export function isCardStyle(v: unknown): v is CardStyle { + return typeof v === "string" && (ALL_STYLES as string[]).includes(v); +} diff --git a/apps/web/src/lib/wall.ts b/apps/web/src/lib/wall.ts index 97c9ea9..e29d610 100644 --- a/apps/web/src/lib/wall.ts +++ b/apps/web/src/lib/wall.ts @@ -1,3 +1,5 @@ +import type { CardStyle } from "./styleHint"; + /** 大屏滚动墙的条目(真实任务或演示卡) */ export interface WallItem { taskId: string; @@ -8,4 +10,6 @@ export interface WallItem { hasScreenshot: boolean; createdAt: number; demoIndex?: number; // 演示卡片:用 srcdoc + /** 大屏卡片外壳(七式之一)。未传 → FilmCard 走 archive 默认。 */ + styleHint?: CardStyle | null; } diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 26557f2..eacec46 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -4,20 +4,23 @@ export const router = createRouter({ history: createWebHistory(), routes: [ { + // 主入口 = 现场大屏(原 /screen);/start 是制作流程页 path: "/", + name: "screen", + component: () => import("./views/ScreenView.vue"), + }, + { + path: "/start", name: "home", component: () => import("./views/HomeView.vue"), }, + // 老链接 /screen(含已印出的二维码)统一跳回大屏 + { path: "/screen", redirect: "/" }, { path: "/verify/:code", name: "verify", component: () => import("./views/VerifyView.vue"), }, - { - path: "/screen", - name: "screen", - component: () => import("./views/ScreenView.vue"), - }, { path: "/admin", name: "admin", diff --git a/apps/web/src/views/AdminView.vue b/apps/web/src/views/AdminView.vue index 674ec11..e620637 100644 --- a/apps/web/src/views/AdminView.vue +++ b/apps/web/src/views/AdminView.vue @@ -189,7 +189,7 @@ const statusVariant: Record< Words2Site 管理台 大屏 {{ probing ? "探活中…" : "codex 探活" }} - + diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index 5e7bd18..888da7d 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -9,15 +9,14 @@ import CertificateCard from "@/components/CertificateCard.vue"; import CpuLogo from "@/components/CpuLogo.vue"; import { Check } from "lucide-vue-next"; -type Step = "intro" | "record" | "info" | "waiting" | "preview" | "certificate"; +type Step = "intro" | "record" | "info" | "waiting" | "done"; const STEPS: { key: Step; label: string }[] = [ { key: "intro", label: "欢迎" }, { key: "record", label: "描述网页" }, { key: "info", label: "填写信息" }, { key: "waiting", label: "生成中" }, - { key: "preview", label: "预览" }, - { key: "certificate", label: "完成" }, + { key: "done", label: "完成" }, ]; const step = ref("intro"); @@ -48,6 +47,7 @@ const stepIndex = computed(() => Math.max(1, STEPS.findIndex((s) => s.key === step.value) + 1), ); const stepLabel = computed(() => STEPS[stepIndex.value - 1]?.label ?? ""); +const stepNum = computed(() => String(stepIndex.value).padStart(2, "0")); const progressPct = computed(() => (stepIndex.value / STEPS.length) * 100); const onTranscribed = (payload: { text: string; seconds: number }) => { @@ -62,7 +62,7 @@ watch( if (s === "done") { waitProgress.value = 100; htmlVersion.value++; - step.value = "preview"; + step.value = "done"; } }, ); @@ -140,7 +140,6 @@ async function publish() { domain: data.publishUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""), email: email.value.trim() || null, }; - step.value = "certificate"; } catch (e) { submitError.value = e instanceof Error ? e.message : String(e); } finally { @@ -238,497 +237,582 @@ onUnmounted(stopWaitingTimers); diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index c874c3c..f6bba55 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.ts","./src/router.ts","./src/composables/useapi.ts","./src/composables/userecorder.ts","./src/composables/usetaskpolling.ts","./src/lib/apibase.ts","./src/lib/demopages.ts","./src/lib/filmribbon.ts","./src/lib/utils.ts","./src/lib/wall.ts","./src/app.vue","./src/components/certificatecard.vue","./src/components/cpulogo.vue","./src/components/filmcard.vue","./src/components/filmwalldesktop.vue","./src/components/filmwallphone.vue","./src/components/lazyframe.vue","./src/components/previewframe.vue","./src/components/recorderpanel.vue","./src/components/sessionboard.vue","./src/components/ui/badge.vue","./src/components/ui/button.vue","./src/components/ui/card.vue","./src/components/ui/textarea.vue","./src/views/adminview.vue","./src/views/homeview.vue","./src/views/screenview.vue","./src/views/verifyview.vue","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.ts","./src/router.ts","./src/composables/useapi.ts","./src/composables/userecorder.ts","./src/composables/usetaskpolling.ts","./src/lib/apibase.ts","./src/lib/demopages.ts","./src/lib/filmribbon.ts","./src/lib/stylehint.ts","./src/lib/utils.ts","./src/lib/wall.ts","./src/app.vue","./src/components/certificatecard.vue","./src/components/cpulogo.vue","./src/components/filmcard.vue","./src/components/filmwalldesktop.vue","./src/components/filmwallphone.vue","./src/components/lazyframe.vue","./src/components/previewframe.vue","./src/components/recorderpanel.vue","./src/components/sessionboard.vue","./src/components/ui/badge.vue","./src/components/ui/button.vue","./src/components/ui/card.vue","./src/components/ui/textarea.vue","./src/views/adminview.vue","./src/views/homeview.vue","./src/views/screenview.vue","./src/views/verifyview.vue","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/deploy/edgeone.md b/deploy/edgeone.md index ef78055..eed2dee 100644 --- a/deploy/edgeone.md +++ b/deploy/edgeone.md @@ -117,7 +117,7 @@ Node 版本: 22 ### SPA 路由回退(必配,仓库已带) -前端是 vue-router **history 模式**,直接访问或刷新子路径(工作人员扫码核验 `/verify/W2S-XXXX`、管理台 `/admin`、大屏 `/screen`)时,静态托管找不到对应文件会返回平台 404。`apps/web/public/edgeone.json` 已配置回退: +前端是 vue-router **history 模式**,直接访问或刷新子路径(工作人员扫码核验 `/verify/W2S-XXXX`、管理台 `/admin`、制作流程 `/start`)时,静态托管找不到对应文件会返回平台 404。`apps/web/public/edgeone.json` 已配置回退: ```json { @@ -149,8 +149,8 @@ curl -i https:///screen | head -3 ### 访问 -- B-1 大屏演示:`https:///screen?demo=12`(后端不可达时自动用内置演示卡片填充); -- B-2 首页即完整参与者流程,正式活动可用。 +- B-1 大屏演示:`https:///?demo=12`(后端不可达时自动用内置演示卡片填充;老地址 `/screen` 会重定向到 `/`); +- B-2 制作流程在 `/start`(主入口 `/` 是大屏),正式活动可用。 ## 常见问题