Workbench Studio · 个人全能工作台生成器
核心认知:产出物不是"一次性定制的应用",而是一个可配置的平台。
工作台 = Widget 引擎(运行时) + 布局配置 DEFAULTS.layout(JSON) + 数据集合 Data(独立存储)
用户拿到的工作台,右上角 ⚙️ 面板里能自己加/删/排 widget;agent 改配置就能改功能,不用重写页面。
工作流程
第 1 步:形态分叉(先问这一句,不许默认往下走)
> ⚠️ 用户说「建个工作台 / 做个看板」时,第一个问题必须是:
> 要出固定模板,还是做个性化设计? 跳过分叉直接套模板 = 违反需求基线 R4。
>
> 例外(R8):用户已经把数据给你了(附件 / 文件路径 / 粘贴的表格)→ 先跑 scripts/datagen.js 读数据,走 1c。
> 不要再问「你是什么岗位」——数据已经回答了一半的问题。
| 用户选 | 走法 | 交付节奏 |
|---|---|---|
| 固定模板 | 走 1a:直接取岗位模板库的现成成品 | 2 轮内交付 |
| 个性化设计 | 走 1b:先做需求访谈,再现场组装 | 访谈 2–3 轮 |
| 给了数据 | 走 1c:先体检数据、认出业务,再定台子 | 2 轮内交付 |
1a. 固定模板路线
读 references/role-templates/(README.md = 8 项契约 + 主题 id 速查):
- 按岗位命中模板——24 个岗位(12 职能 + 12 行业)各 1 主 + 1 备选:
默认给主模板;用户想要不同形态时,把备选模板作为第二方案让其二选一。 - 每套模板已定义 8 项:模板名 / 对标来源 / 适用场景 / 继承范式 / 版块骨架 / 指标覆盖 / 首出主题 / 反模式。照着填,不临场发明。
- 骨架来自范式层:读
references/blueprints.md里该模板「继承范式」指向的 A 或 B,
把模板的「版块骨架 + 指标覆盖」叠上去 → 得到完整sections配置。填「无」的按模板内写的骨架自建。 - 逐条自检「反模式」——每套模板都列了该岗位最容易做错的事,生成时按条排除。
- 只再确认 1–2 件事(数据来源、是否多用户),不连环追问。
- 没命中任何岗位 → 回退
references/scenarios.md第四节速配表现场组装。
1b. 个性化设计路线
个性化 ≠ 从零:先取最接近的岗位模板当底子,再按访谈结果增删版块与指标。
六问清单(一次问 2–3 个,不要一口气全抛;用 AskUserQuestion 给选项降低回答门槛):
| # | 问什么 | 用来决定 |
|---|---|---|
| 1 | 什么岗位 / 什么行业? | 选哪套岗位模板做底子 |
| 2 | 每天 / 每周最想在这屏看到哪几个数? | 头条层指标(≤4 张) |
| 3 | 手上有什么数据、从哪来? | 数据集合与字段(衔接导入) |
| 4 | 要盯的「事」是什么流程? | 要不要看板 / 漏斗 / 台账 |
| 5 | 有偏好的风格吗? | 首出主题(覆盖模板默认值) |
| 6 | 要不要多人用 / 登录? | 单页 HTML vs 全栈 |
交付自检:访谈出的每一条,都要能在产出里找到落点——问过的事必须体现,没问的事不要自作主张。
1c. 数据驱动路线(用户带了数据 → 不要先问岗位)
> 数据已经把「这是什么业务」回答了一半。先读数据,再定台子——规则真源见 references/data-first.md。
node scripts/datagen.js <数据文件...> [--json]
执行顺序:
- 先跑体检:脚本一次性给出 —— 字段画像(每列类型 / 唯一值 / 空值率 / 数值范围)、
业务识别(Top 候选 + 得分 + 证据列)、命中的岗位与模板(含首出主题 / 继承范式 / 对标来源)、
可直接用的sections蓝图、落地档位(内嵌 / 样本 / 只给结构)、隐私列警告。 - 把「我认出了什么」摊给用户看(别把 JSON 倒给用户):一句话业务判断 + 证据列 + 推荐模板 + 备选。
命中多个候选时让用户选,不要替他拍板(数据里同时像「库存」又像「投放」是常态)。 - 只再确认 1–2 件事(同 1a 的克制):哪张表是重点、要不要留副集合的表、有没有目标值
(有目标才能出kpi达成卡,没有就别硬造)。 - 按蓝图生成:
theme取模板的「首出主题」、sections直接用蓝图(它就是「范式骨架 + 模板覆盖」的成品),
种子按落地档位写(A ≤500 行全量 / B 前 20 行 / C 只留结构)。生成方式与 1a 完全相同,不额外造轮子。 - 缺口如实说:蓝图里的
gaps是「这份数据暂时出不了的卡」(缺目标列 / 缺安全线 / 缺分子分母列…),
交付时讲清楚,别让用户以为是产物坏了。能补的列要给出补法(如「加一列安全库存就能标红」)。
识别不出来时(只有 ID 与时间戳之类):不要硬套模板。走通用明细台,并按 references/data-first.md
第六节如实告知「没认出是什么业务,请你一句话说明」,同时把画像里最像线索的列名报给用户。
两条路共同的收尾
- 确认「要看什么」而非「要哪个功能」:多问一句「你想在这屏看到哪几个数 / 哪几张表」,比直接套模板更贴需求。
- 按设计准则裁剪:单版块 5–10 个指标、≤10 个可视化;头条放核心数、明细收尾;剔除虚荣指标(曝光 / 粉丝数 / 上线功能数)。
第 2 步:运行模式与形态(先定「跑在哪」,再定「怎么建」)
在第 1 步(形态分叉)之后紧接着问一句:「这台工作台是本地自己用,还是要发布到线上?」——这决定安全等级(R6,清单见 references/security.md)。
| 运行模式 | 含义 | 安全等级 |
|---|---|---|
本地 runtime:'local' |
双击 HTML / 本机跑,数据不出本机 | 基本无要求,不要打扰用户 |
线上 runtime:'online' |
发布公网,拿到链接的人都能访问 | 强制过安全清单 + 发布闸门,安全级别要高 |
数据敏感度定形态(要发布线上时必问,一题定生死):
| 数据级别 | 典型内容 | 允许形态 |
|---|---|---|
| L0 公开 | 演示数据、公开信息 | 单页 ✅ / 全栈 ✅ |
| L1 内部 | 内部项目名、排期、进度、非敏感台账 | 全栈 ✅(推荐)/单页 ⚠️ 须脱敏 |
| L2 个人 | 姓名、手机、邮箱、工号、客户联系人 | 仅全栈 ❌ 单页 |
| L3 商业敏感 / 涉密 | 报价、金额、供应商、合同、BOM | 不发布线上,走内网 / VM |
| 条件 | 形态 | 模板 |
|---|---|---|
| 个人使用、数据可本地化、无需登录 | 单页 HTML | assets/spa-template/index.html |
| 多用户、登录鉴权、读服务器数据 | Node.js 全栈 | assets/fullstack-template/ |
拿不准时默认单页 HTML。全栈形态下,服务端数据用 apiTable widget(改 server.js 端点即可)。
> ⚠️ 必须理解的安全边界:单页 HTML 发布线上 = 源码里的一切对任何访问者可见(内嵌示范数据、注释、凭据,右键看源码即可)。
> 用户自己录入的数据只存在各自的浏览器里,不跨用户泄露 → 所以单页上线防的是「源码里写了什么」;全栈上线防的是「谁能登进来」。
> L1 以上必须脱敏,L2 以上必须走全栈。 详见 references/security.md 第 1–2 节。
第 2.5 步:主题(首出随模板,默认不问用户)
主路径:直接取所用岗位模板的「首出主题」字段,写进 DEFAULTS.theme。
生成时告知一句即可:「已按 XX 模板配了 YY 主题,右上角可随时换」。
- 这块的单一真源是岗位模板的「首出主题」字段(主题 id 速查见
references/role-templates/README.md)。
不要再单独维护一份「模板 → 主题」对照表——一处定义两处受益(R1 与 R2 合并)。 - 只有两种情况才展示主题清单:
① 用户在 1b 访谈里表达了风格偏好 → 以用户为准,覆盖模板默认值;
② 用户主动问「有哪些主题可选」→ 才读references/themes.md展示分组主题清单。 themes.md的分组货架:8 配色 + 12 风格 + 8 新锐 + 1 萌宠 + 10 热门 IP + 5 流行趋势 + 6 开发者经典(共 50 套)。
热门 IP 类仅配色致敬,不含版权素材,呈现时向用户说明。themes.md里的「推荐岗位」是反向索引;正向映射以岗位模板的「首出主题」为准。- 生成后内置切换器(分组下拉 + 浅/深按钮),50 套随时可换、刷新后保持。
第 2.7 步:特效选择(特效引擎)
读 references/effects.md。6 项:🧊 立体空间、🎯 物理碰撞、🔄 3D 翻转(点标题栏翻面看 widget 配置)、📱 桌屏联动、✨ 桌面动效(粒子跟随主题)、🌦 天气换肤。
默认全开(v3.15 起):DEFAULTS.effects 直接写 6 项,不用再逐项问用户要哪个——一句话交代「特效默认都开着,觉得吵在 ⚡ 面板里关掉」即可。
开关方式与主题同级(v3.17 起):⚡ 常驻 header 工具栏(就在主题下拉旁边),按钮上带已开启项数的徽标;面板按 3 组分类(视觉质感 / 交互反馈 / 环境氛围)+ 每项一句说明,底部有「全部开启 / 全部关闭」,点面板外自动收起。和主题一样是「一眼看到、随手可改、刷新保持」——向用户交代时别把它说成"藏在某个按钮里"。
首装时 EffectStore.guards() 会按设备能力拦下动效类(系统「减弱动态效果」→ depth/physics/ambient;触屏 → physics/depth),拦了就在 ⚡ 面板底部写明原因,同时徽标显示的是真实在跑的项数(不是 6)。用户手动改过开关之后一律以用户选择为准,守卫不再介入。
第 3 步:生成
- 复制模板到用户工作区(不原地改模板):
<workspace>/workbench-<岗位英文名>/(如workbench-product/workbench-sales)。 - 改
DEFAULTS这一个常量(模板顶部,四层架构的接线盒):
js const DEFAULTS = { title: '项目跟踪工作台', ns: 'product', // ⚠️ 必填:存储命名空间,用岗位英文名(见下方说明) theme: 'indigo', // 第 2.5 步所选 mode: 'light', runtime: 'online', // 第 2 步所选:'local' 本地自用 | 'online' 已发布公网(自动 noindex + 面板安全条) effects: ['depth','physics','flip','dock','ambient','weather'], // 默认全开(v3.15);要关就整项删掉 layout: { sections: [ /* 第 1 步岗位预置,可增删改 */ ] } };
⚠️effects的默认值是「全开」:不要图省事写[](那等于把 6 项全关,用户会以为特效坏了),也不要写Object.keys(...)之类的隐式全开——写显式清单,改的时候看得见。
⚠️ 不要在ThemeStore.init()之后再用localStorage写默认值——init 会先把默认值落盘,导致后续覆盖失效。所有默认值都放DEFAULTS。
⚠️ns必须给唯一值:多个工作台可能共用同一源(同一站点、或桌面预览面板的所有 HTML 同端口),key 不带命名空间会互相覆盖/串台——轻则数据丢失,重则整台被别的工作台布局顶掉。命名空间作用于 layout / data / theme / effects / sources 全部存储。 - 按需补数据种子:把示例数据写入数据集合,逐个集合补种(缺哪个补哪个,不要用「有任何数据就整体跳过」的判断——同源残留数据会把整批种子顶掉):
js const d = Data.load(); const put = (k, v, silent) => { if (!d[k]) Data.set(k, v, silent); }; put('items', [ /* 示例数据 */ ]); - 图表用内联 SVG(零外网依赖);配色遵循涨红跌绿、金额默认 ¥。
- 选 widget 先查更多组件清单,再考虑新增类型(R3):模板骨架里出现「达成率 / 趋势 / 转化 / 进度 / 里程碑 / 库存水位」这类字眼时,去
references/widgets-ext.md取对应 widget(kpilinefunnelstepsrankprogresstimelinestock)——它们已注册在引擎母本里,直接写配置即可,不要去实例里各写各的。
⚠️ 转化类先做一次判别:同列不同值用funnel(一行一个阶段取值,数几行落在哪层)、同值分列用steps(访客/加购/下单/成交各占一列,求各列合计)——两者不可互换。
只有现有 18 种确实没有时才新增类型,且必须加进引擎母本(不是加在生成实例里),否则下个实例要重写一遍:
js /* assets/spa-template/index.html → WIDGETS 数组「更多 widget」区段 */ WIDGETS.push({ type: 'myWidget', name: '📌 我的组件', desc: '一句话说明', defaults: { title: '标题', span: 6 }, render(el, cfg) { /* 渲染到 el;数据读写走 Data.get/set(cfg.dataRef);取色只用主题变量 */ } });
新增后跑selftest+sync_fullstack+validate_catalog,并把新类型回填widgets-ext.md的三张表。
比值类指标(ROI / 周转率 / 投产比)不用新增类型:通用组件stat的op:'ratio'(field分子 ÷field2分母,默认后缀x)直接可表达;要算「各分组」的比值就得另导一份分组汇总集合——引擎的分组统计只支持计数 / 求和。 - 全栈形态额外:只改
server.js的「业务 API 端点」那一段——上面 13 项安全实现(scrypt 哈希 / 随机密钥 / 首启引导页 / 限流 / 安全头 / 审计)不要删;前端public/app.html由脚本生成,不要手改引擎段。首启没有内置账号,第一个访问者会看到初始化页自己设管理员口令(也可以ADMIN_PASSWORD环境变量 + 自建账号)。 - 发布线上(
runtime:'online')时数据必须脱敏:内嵌示范数据换成虚构值(无真实姓名 / 手机 / 邮箱 / 客户名 / 金额),否则等于公网公开(第 5 步有闸门强制卡)。
第 3.5 步:数据接入(导入即统计 → 自动出视图)
先分清三种「数据进来」的场景,走错会白干一轮:
| 场景 | 走哪条 |
|---|---|
| 还没有台子,用户只有一份数据 | 1c 数据驱动建台 → 先跑 scripts/datagen.js(认业务 → 定台子) |
| 台子已建好,用户要导入自己的数据 | 本节(引擎运行时:画像 → 自动建议 → 生成视图) |
| 台子已建好,只想换某张卡的数据 | 卡片标题栏的 📥(R7:覆盖 / 追加) |
用户手上已有数据(Excel 导出的 CSV / 接口 JSON / 一段文本)时,不要手工猜字段配一堆卡——引擎已内置这条链:
| 环节 | 引擎能力 | 生成时要说的话 |
|---|---|---|
| 解析 | 按魔数判类型:.xlsx 走 DataStore.parseXlsx()(zip+OOXML 直读,多工作表、共享字符串、公式缓存值、日期序列号全覆盖);其余走 DataStore.parseAny() 文本嗅探(JSON/CSV/TSV/YAML/XML/MD 表格/INI/纯文本…;也支持连 URL)。编码自动嗅探(UTF-8/UTF-16/GBK——中文 Excel 另存 CSV 默认 GBK) |
交付说明写「直接把 Excel(.xlsx)拖进来就行,CSV/JSON 也可以;旧版 .xls 请先另存为 xlsx 或 csv」 |
| 画像 | DataStore.profile() 推断每列类型(数值/日期/布尔/状态/类别/文本)+ 唯一值数 / 空值率 / 数值范围 |
不用用户说明哪列是数值 |
| 建议 | DataStore.suggest() 数值列→求和卡、类别列→柱状图、状态列→看板、日期列→折线趋势、无分组列→明细表 |
导入完成即自动追加到当前版块 |
| 记忆 | sources[name].mapping 记住字段选择 |
同名集合再导入沿用上次映射 |
| 就地换数 | 卡片标题栏 📥(R7)→ 「覆盖 / 追加」→ 选文件:只换这张卡所读集合的数据,卡片配置不动、其余同源卡片自动同步 | 交付说明写「鼠标移到卡片上,标题栏会出 📥,能直接把你自己的表导进这张卡」 |
生成侧的两条纪律:
1. 种子数据要种成「可被画像识别的结构」——对象数组、列名写实、数值存 number、日期用 YYYY-MM-DD。别种 [{a:1}] 这种无意义字段,否则用户导入真数据后自动建议质量对不上。
2. 用户只说「我有一堆数据」时,走「轻种子 + 让用户导入」路线:种 5–10 行示例便于开机不空,其余交给导入自动统计。
第 3.7 步:告诉用户怎么自助改(R4-1 可视化配置)
⚙️ 面板里除 JSON 配置区外,已内置「可视化配置」区:集合 / 字段 / 统计方式全是下拉(选项来自上面同一份字段画像),选已有卡片可回填修改;空版块点了会给「从模板加卡片 / 可视化配置 / 导入数据自动统计」三个入口。
交付说明优先引导用户走这条路(不写 JSON),把 JSON 配置区当高级选项。
「卡片的数据来源怎么换」——高频追问,交付时主动讲清
卡片不直连数据,链路是 卡片 →(dataRef)→ 数据集合 →(来源)→ 种子 / 文件 / URL。所以「换信息来源」分两个层级,五条路径:
| 路径 | 操作 | 适用 |
|---|---|---|
| A 卡片上的 📥(最省事,R7) | 鼠标移到目标卡片 → 标题栏出现 📥 → 点它 → 选「覆盖」或「追加」→ 选文件 | 只换数:卡片配置一个字不动,且读同一集合的其余卡片自动同步 |
| B 面板同名覆盖 | ⚙️ → 数据集合区 → 集合名填成模板里已有的集合名 → 导入文件 | 还没有对应卡片可点(先建集合再配卡) |
| C 换数据源 | ⚙️ → 可视化配置 → 顶部下拉选已有卡片(表单回填)→「数据集合」切到新集合 → 字段下拉跟着换 → 点「更新此卡片」 | 新表结构不同 / 只改某几张卡 |
| D 连 URL | ⚙️ → 数据集合区 → 填集合名 → 「连接 URL」→ 集合行出现「刷新」 | 数据要定期更新(file:// 下跨域会被拦,需本地服务或线上) |
| E 改根 | 改 index.html 里的种子块与布局 sections |
永久改默认(面板的布局 JSON 导出/导入不含数据) |
📥 的两个选项要说清(用户会问):
- 覆盖 = 清空该集合现有行、整份换成文件内容(有二次确认,防误点);
- 追加 = 保留现有行、把文件内容接在后面(引擎会对撞号的 id 重新编号,否则看板点卡片会改错行);
- 导入后 toast 会报告「几行 → 共几行」,列名对不上时明确列出缺列——卡片认字段名,缺列只会渲染成空/0 且不报错,必须让用户看到这句。
- 纯展示卡没有 📥(链接 / 笔记 / 倒计时 / 图片)——数据不是数组,导入无意义。14 种带数据来源的 widget 全部有(实测 18 种逐一验过)。
必须一并提醒的四个坑(不说用户就会以为「改了没生效」):
1. 卡片认字段名——卡里写 成交金额、新表叫 销售额 → 渲染成 0 或空,且不报错;
2. filter 也是按字段值匹配(库存预警=是、状态=滞销)→ 取值不存在就变 0;
3. kanban.groups / funnel.stages 是写死的取值列表 → 阶段名不同会漏列(面板里重选字段可让引擎按新画像重算 groups);
4. 数据存浏览器 localStorage(按 ns 隔离)→ 换机器 / 清缓存会回到内置种子,所以重要的要留原表。
第 4 步:验证
- 单页:无头浏览器断言关键路径——默认 widget 数量、主题切换与持久化、⚙️ 添加/删除/改宽度、配置 JSON 应用、数据集合共享(清单录入后统计卡同步)、特效开关。
- 数据链路必验(v3.8 起):导入一段带列结构的文本 → 断言自动生成的视图数量与类型正确;再导入同名集合 → 断言沿用映射、不重复堆卡;导入纯文本 → 断言只提示不生成。
- Excel 直读必验(v3.11 起):拿真实 .xlsx(不是改名的 CSV)走一次上传 → 断言行数、中文共享字符串、日期列还原为
YYYY-MM-DD且画像识别为date、空单元格没变成 0、多工作表都进了库;再验 GBK 编码 CSV 中文不乱码、旧.xls给的是「另存为」提示而不是乱码集合。用 openpyxl 交叉确认测试用的 xlsx 本身合法,否则测的是「解析器的错」而不是「Excel 的文件」。 - 卡片级导入必验(R7 起):断言 14 种带数据来源的 widget 都有 📥 而纯展示类都没有;走一次真实 xlsx 的追加→ 断言行数相加、id 全唯一、读同一集合的其余卡片同步重算;再走一次覆盖(列结构不同)→ 断言缺列告警出现、缺列卡安全归零(出
¥0而非NaN)、二次确认弹过。 - 数据驱动建台必验(R8,用户带数据来时):①
node scripts/datagen.js <文件> --json的画像与页面里DataStore.profile()逐列一致(类型 / 唯一值 / 空值率 / 数值范围)——不一致就会出现「脚本说能出图、运行时渲染 0」;② 断言业务识别命中预期岗位,且烂数据(只有 ID 与时间戳)不被硬推荐;③ 用蓝图生成的实例,逐版块切换后卡片要渲染出数据里的真实数值(求和 / 比值 / 计数都对得上按原数据算出的期望值,不是 0、不是空)。 - 可视化配置必验:面板里选出集合/字段/统计方式 → 添加 → 断言卡片出现在当前版块且渲染出数。
- 运行模式必验(R6):
runtime:'online'→ 断言已注入noindexmeta、⚙️ 面板有安全条、一键清空本机数据生效;runtime:'local'→ 断言没有安全条(不打扰用户)。 - 「用户的更改存得住」必验(v3.15 起 · 四段场景,用户最在意的点):
1. 主链路:添加一条自有待办 + 改备注 + 勾选 →reload→ 断言全部还在 → 再触发一次实时刷新 → 断言仍未被业务数据覆盖;
2. 升级回归:setItem一份旧布局(含"用户改过的卡片标题 + 用户自建卡片 + 用户删掉过默认卡")→reload→ 断言标题在、自建卡在、删掉的卡没被补回、新卡补上了、再刷一次幂等;
3. 离线兜底:file://打开 → 断言状态条为离线快照、图表仍可用、点图条仍能弹明细;
4. 存储被禁:evaluateOnNewDocument里把localStorage的 getter 改成抛SecurityError→reload→ 断言页面没崩(卡片与图表照常渲染)+ 状态条明确警示"保存不了"。 - 断言要按特征元素定位组件(清单 =
input[type="checkbox"]、表格 =table、自定义图 = 自己的 class);
用通用表单 class(如input.w-input)判断会误伤——表格卡的筛选框用的是同一个 class。 - 安全闸门必跑(R6 · 发布线上前的硬卡点):
node scripts/security_check.js <产物目录> --runtime online
输出里有任何 BLOCK 就不许发布,按提示逐条修完再发;WARN 视情况处理(清单对照references/security.md)。 - 全栈:
node --check server.js→ 起服务(run_in_background)→ 走通 首启初始化页建管理员 → 登录 → 读 → 写 → 改 → 删,并额外验三条安全行为:连续错口令触发 429、无 token 访问/api/records返回 401、改密后旧 token 立即失效(本机 curl 必须加--noproxy '*')。 - 验证通过再交付,不交半成品。
第 5 步:交付与发布
present_files展示入口文件(单页)或本地服务 URL(全栈)。- ⚠️
present_files的内置预览对本地 HTML 走「静态快照」通道(URL 形如http://127.0.0.1:<port>/static-html/<hash>/index.html):脚本不执行 = 只能看、不能点。用户反馈「点不动 / 只能查看」时按此处理,不要急着改产出代码:
1. 先自证产出可交互:用无头浏览器直接访问那个预览 URL,点 Tab / 点卡片工具按钮断言是否生效(实测多为面板通道限制,而非产出缺陷);
2. 再给可交互通道:① 浏览器直接双击index.html(file://下交互正常,但存储策略随 Chrome 版本/策略浮动:可能允许,也可能抛SecurityError→ 必须已注入「存储守卫」才会优雅降级;所以有本地服务的实例一律引导用 bat);② 本地起服务python -m http.server <port> --bind 127.0.0.1后开http://127.0.0.1:<port>/index.html;③ 需要分享就用workbuddy_sites_deploy发布线上。
3. ⚠️file://与http://127.0.0.1:<port>是两套 localStorage:同一个用户在两处做的更改互不可见("我改了重开就没了"的主因之一)。交付时要宣布唯一入口,并让页面状态条按当前模式给出对应文案。
3. 生成本地实例时顺手放一个「启动预览.bat」(检测端口 → 没起就python -m http.server→ 打开浏览器)。注意 bat 必须用 GBK 编码写(中文 Windows cmd 直接可读,UTF-8 会乱码),且不要用Start-Process -WindowStyle Hidden起常驻服务——宿主机进程清理会回收它,用户自己双击的窗口才稳定。 - 用户要线上访问时,按这个顺序走(R6 · 高安全级别):
1. 切运行模式:单页实例把DEFAULTS.runtime改成'online'(全栈模板已默认 online)——不改则线上安全条与 noindex 都不生效;
2. 过安全闸门:node scripts/security_check.js <产物目录> --runtime online,有 BLOCK 先修完再发(重点:内嵌示范数据脱敏、无本机/内网地址、无凭据、noindex 到位);
3. 发布:workbuddy_sites_deploy(单页 static / 全栈 node),回复分享链接;
4. 告知风险边界(必说):「链接即钥匙——拿到链接的人都能打开,也能看到页面里内嵌的示范数据;你自己录入的数据只存在本机浏览器」;全栈则说「首个访问者会看到初始化页,请自己设管理员口令,不要用弱口令」。 - 发布失败(
fetch failed/ 500)重试 1–2 次即止;连续失败说明服务端故障,停手告知用户稍后再试,不要连续重试触发 429 限流。
第 5.5 步:改结构必做 · 缓存升级闸门(漏做必被用户投诉「看不到」)
单页工作台的「数据」和「版块结构」都存在浏览器 localStorage 里,重新生成的 HTML ≠ 用户看到的页面:
- 数据集合 key
workbench_data_v1__<ns>:只判「集合是否存在」→ 旧数据永不被覆盖 → 新计算列全 undefined、新卡片永远显示 0。 - 布局 key
workbench_layout__<ns>:Layout.get() || DEFAULTS.layout→ 缓存存在时新的DEFAULTS.layout完全不生效,连版块入口都没有(用户最容易误判成「功能没做出来」)。
纪律:改版块 / 卡片结构 → 布局版本 +1;改数据口径 → 种子版本 +1。两者都要注入升级脚本,且必须在 Layout.render() 之前执行。
升级必须「只增不减 + 墓碑」(用户明确要求「我的更改要记住」时尤其重要)
整体替换默认布局 = 用户改过的卡片标题、删掉的卡片、调过的顺序全部丢失。正确做法是以用户当前布局为基础,只做增补:
(function upgradeLayout() { /* 注入到 Layout.render() 之前 */
const LV = 5; /* ← 布局版本,改版块/卡片就 +1 */
const sfx = DEFAULTS.ns ? '__' + DEFAULTS.ns : '';
const KEY = 'workbench_layout' + sfx, ACT = 'workbench_active_section' + sfx;
let cur = null;
try { cur = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) { cur = null; }
if (!cur || cur.__lv === LV) return; /* 首装 / 已最新 → 不动 */
const PREV = [...]; /* ⚠️ 上一版默认的卡片 id 全集(构建脚本顶部常量,改版时必须更新) */
const def = DEFAULTS.layout, clone = o => JSON.parse(JSON.stringify(o));
const defIds = new Set(); def.sections.forEach(s => (s.widgets || []).forEach(w => defIds.add(w.id)));
const DEAD = PREV.filter(id => !defIds.has(id)); /* 上一版有、本版没有 → 废弃卡,清掉 */
const removed = Array.isArray(cur.__removed) ? cur.__removed.slice() : [];
const curIds = new Set();
(cur.sections || []).forEach(s => (s.widgets || []).forEach(w => curIds.add(w.id)));
/* 上一版默认里有、用户布局里没有 → 用户主动删除 → 墓碑,以后不再补回(注意:这里不能再加 !defIds.has(id) 条件,
否则本版仍在用的卡(如 w12)会被漏判 → 用户删掉的卡又冒出来) */
PREV.forEach(id => { if (!curIds.has(id) && removed.indexOf(id) < 0) removed.push(id); });
const next = Object.assign({}, cur, { __lv: LV, __removed: removed }); /* ← 以用户布局为基础,不替换 */
next.sections = (cur.sections || []).map(s => Object.assign({}, s, {
widgets: (s.widgets || []).filter(w => DEAD.indexOf(w.id) < 0)
}));
/* ⚠️ 版块匹配只用【name】不能用 id(历史版本里 s2 可能被别的版块占用过);
再补一层「去掉 emoji/符号后归一化」比对,兼容用户改名 */
const norm = s => String(s == null ? '' : s).replace(/[^0-9A-Za-z\u4e00-\u9fa5]/g, '');
const haveIds = new Set(), usedSecIds = new Set(next.sections.map(s => s.id));
next.sections.forEach(s => (s.widgets || []).forEach(w => haveIds.add(w.id)));
const uniqSecId = id => { let x = id, i = 1; while (x == null || usedSecIds.has(x)) x = id + '_' + (i++); usedSecIds.add(x); return x; };
const freshIds = [];
def.sections.forEach(ds => {
const host = next.sections.find(s => s.name === ds.name)
|| next.sections.find(s => norm(s.name) && norm(s.name) === norm(ds.name));
if (!host) { /* 全新版块:追加,且 id 必须查重 */
const cp = clone(ds);
if (usedSecIds.has(cp.id)) cp.id = uniqSecId(cp.id); else usedSecIds.add(cp.id);
next.sections.push(cp); freshIds.push(cp.id);
(cp.widgets || []).forEach(w => haveIds.add(w.id));
return;
}
(ds.widgets || []).forEach(dw => { /* 已存在版块:只补它还没有的卡 */
if (haveIds.has(dw.id) || removed.indexOf(dw.id) >= 0) return;
host.widgets.push(clone(dw)); haveIds.add(dw.id);
});
});
try {
localStorage.setItem(KEY, JSON.stringify(next));
if (freshIds.length) localStorage.setItem(ACT, freshIds[0]); /* 只在出现全新版块时切激活版块 */
} catch (e) {}
})();
改版块结构的三条硬规矩(踩过才知道):
- 重构某个版块时,新卡片的 id 必须换一批新号段。若沿用旧 id,
haveIds.has(dw.id)会命中 → 新卡永远补不进用户布局(用户看不到任何变化,以为没做)。例:v4 的 s2 占用w201–w212,v5 重构时改用w52x。 - 废弃卡自动推导,别手工维护清单:
DEAD = 上一版默认 id 里、本版默认 id 中已不存在的。 - 追加新版块前检查 id 占用。两个 id 相同的版块会让
Layout.section()取到错的那个 → 打开后停在错误的版块、新卡片 0 条。
- 种子同理:
__seedv落后 → 整体替换业务集合;tasks/ 笔记等用户可编辑集合永远「缺失才写」,不许吞用户录入。 - 验证必须覆盖该场景:无头浏览器先
setItem一份旧布局/旧数据再reload,断言:新结构可见 +__lv已写 + 用户自建卡片仍在 + 用户改过的卡片标题仍在 + 用户删掉的卡没有被补回 + 新增版块自动激活 + 幂等(再刷一次不重复升级)。干净 profile 永远测不出这个 bug。 - 用户反馈「重新打开没看到新东西」→ 先查 localStorage 里这两把缓存,别急着怀疑产出代码。
- 数 tab 数量时记得过滤掉「+ 版块」按钮,否则断言「7 个版块」会取到 8 个。
第 5.6 步:数据在本机文件(Excel / CSV)里 → 上「本地实时服务」
用户说「读我 OneDrive/本机的 xxx.xlsx,要实时,不要快照」时,单页 HTML 双击打开是做不到的(浏览器沙箱读不到磁盘路径)。可行且已验证的做法:
- 加一个零依赖本地服务
serve.js(Node 内置模块即可):
- 静态托管产物目录 +GET /api/orders返回解析后的行数据 +GET /api/health;
- 端口默认 8787(避开其他项目占用),EADDRINUSE要提示「换端口」;
- 解析交给 Python + openpyxl(read_excel.py),结果写 JSON 文件而不是 stdout(Windows 控制台 GBK,中文 stdout 易编码错)。 - Excel 常被打开/同步占用 →
PermissionError Errno 13时不要放弃:
fs.copyFileSync(src, tmp)走共享读,表格开着也能复制成功;解析临时副本不受锁影响。失败退避重试 5 次。 - 缓存:按
mtimeMs + size做 key,未变就用上次结果(2591 行解析不便宜);再叠一个 30s 短缓存。 - 页面侧双模式:
/^https?:$/.test(location.protocol)→ 有服务就fetch('/api/orders')实时重算,否则用内嵌快照;
顶部加一条状态条写明「🟢 实时数据 · N 条 · Excel 更新于 xxx」或「⚪ 离线快照|要实时请双击启动.bat」,
拉取失败保留快照并给「立即刷新」按钮。可选:5 分钟自动刷新,但document.activeElement是输入框时让路。 - 计算逻辑必须抽成双用模块(UMD:Node
require+ 浏览器window.X),构建期快照与页面实时重算共用同一份。
验证时用 Node 侧独立重算,与页面渲染值逐条比对——否则迟早出现「快照和实时两个结果」。 - 附一个一键启动 bat(纯 ASCII 内容 +
chcp 65001):启服务 → 等 2–3 秒 → 打开浏览器 → 提示「关窗口即停服务」。 - 「系统生成 + 用户可勾选」的清单(如按订单生成的待办)必须给每条一个稳定 key,
刷新时mergeDone(新条目, 旧集合)按 key 认领旧done,用户手加条目保留——否则每次自动刷新都会把勾选清零。
⚠️ 注入源码时的致命陷阱:html.replace(needle, 拼接了源码的字符串) —— 替换串里的 $'、$&、$1、$$ 都是占位符。
源码里只要有 '$' + 金额 这类写法,$' 就会被展开成「匹配之后的全部内容」,当场把脚本截断
(症状:静态外壳还在、整段 JS 语法错误、所有全局 const 变 undefined、pageerror: Invalid or unexpected token)。
→ 一律写 replace(needle, () => newText);并在构建脚本末尾加产物自检:new vm.Script(每个内联 script 块),编译不过就让构建失败。
第 5.7 步:让「用户的更改」真的存得住(用户说「改了要记住」时必做)
用户反馈「我改了东西,重新打开就没了」时,八成不是代码没写保存,而是下面三件事之一:
- 存储可能被浏览器禁掉,且会把整段脚本打挂:Chrome 对
file://文档在部分策略下访问localStorage直接抛SecurityError,
而模板的Data.save()没有 try/catch → 异常上抛 → 整个<script>块剩余语句全不执行
(症状:tab 出来了、卡片零星几张、图表全空、状态条不出现,看着像"半成品")。
→ 注入存储守卫:探测不可用就换内存实现 + 兜底。位置必须在ThemeStore.init()之前(它们开场就读存储)。
(function storeGuard() {
let ok = true;
try { localStorage.setItem('__wb_probe__', '1'); localStorage.removeItem('__wb_probe__'); }
catch (e) { ok = false; }
if (!ok) {
const mem = {}, shim = {
getItem: k => (Object.prototype.hasOwnProperty.call(mem, String(k)) ? mem[String(k)] : null),
setItem: (k, v) => { mem[String(k)] = String(v); },
removeItem: k => { delete mem[String(k)]; },
clear: () => { Object.keys(mem).forEach(k => delete mem[k]); },
key: i => Object.keys(mem)[i] || null
};
Object.defineProperty(shim, 'length', { get: () => Object.keys(mem).length });
try { Object.defineProperty(window, 'localStorage', { configurable: true, get: () => shim }); } catch (e) {}
window.__WB_MEM_ONLY = true; /* 页面据此提示「这个模式保存不了」 */
}
try { /* 再兜一层,绝不让存储异常打挂页面 */
const s = Data.save.bind(Data), l = Data.load.bind(Data);
Data.save = function (d) { try { s(d); } catch (e) { window.__WB_MEM_ONLY = true; } };
Data.load = function () { try { return l(); } catch (e) { return {}; } };
} catch (e) {}
})();
-
file://与http://127.0.0.1:PORT是两套 localStorage:同一份工作台,双击文件和用 bat 起服务打开,
用户改的东西各存一份、互不可见 —— 这是「改了重开就没了」最常见的主因。
→ 交付时明确宣布唯一入口(有本地服务的一律用"启动工作台.bat");页面上按当前模式给不同文案:
http → 「我的待办 N 条 · 备注 N 字(自动保存)」;file:// → 醒目警示「浏览器禁止该模式保存数据,请用启动.bat」。 -
加「备份我的更改 / 恢复」:导出
{ layout, theme, effects, data: {tasks, notes...} }为 JSON
(只带用户内容,不带业务数据,所以文件很小、也顺带脱敏),恢复时写回并location.reload()。
用户换浏览器 / 换机器 / 误清缓存都能救回来。
第 5.8 步:模板没有的交互 → 注册自定义组件(如「点图条弹明细」)
用户要「按维度出图 + 点击条弹出该组明细」(老工作台「工作」页那种)时,模板的 chart 覆盖不了:
它不支持点击下钻,且组数 >30 时柱宽会算成负数。WIDGETS 是普通数组,直接 push 注册新类型即可。
WIDGETS.push({ /* ⚠️ 必须在首次 Layout.render() 之前注册 */
type: 'barchart', name: '📊 汇总条形图(点条弹明细)',
defaults: { title: '汇总图', dataRef: '', field: '', filter: '', span: 6, head: '📌 ' },
render(el, cfg) { /* cfg = {...defaults, ...widget 配置} */
const list = DataStore.filtered(cfg.dataRef, cfg.filter); /* 复用模板过滤器:'字段=值'、'字段!=值'、'&' 连接 */
/* 业务顺序优先、其余按数量降序;配色走主题色板(见第 5.9 步,不要写死色值);条 = label + track + fill + val */
el.innerHTML = keys.map(k => '<div class="wbc-row" data-k="' + esc(k) + '">…</div>').join('')
+ '<div class="wbc-hint">👆 点击任意图条,弹出该组明细</div>';
el.onclick = e => { const r = e.target.closest('.wbc-row'); if (r) Board.open(cfg, r.dataset.k, list); };
}
});
- 明细弹窗内容自足:
PO# · PN标题行 + 元信息行(项目/数量/供应商/状态/ETD/ETA/预期/Tracking/Mark)+→ 下一步动作,
排序「超期优先 → 超期天数 → 预期日期」,头部显示该组单量与金额合计。 - 注册时机是死线:
WIDGETS.find(w => w.type === wc.type)找不到就return,卡片静默消失(不报错、不显示)。
注入到ThemeStore.init();之前最稳(initPanel()之前还不够,它后面紧跟首次render())。 - 点击绑
el.onclick =而非addEventListener:每次 render 会新建 widget 容器,赋值式天然幂等,不会累加监听。 - 弹窗挂
document.body(不要塞进 grid),样式只用模板 CSS 变量(--card/--text/--sub/--border/--bg/--primary),
遮罩点击 + ESC 都要能关。 - CSS 变量白名单:模板只定义
--bg / --card / --text / --sub / --border / --primary / --radius / --card-deco / --font-title。
用var(--bg-2)、var(--danger)这类不存在的会渲染成透明/黑。 - 配色别一色到底,也别写死:按业务维度给每类固定色,但色值必须从主题主色派生(见第 5.9 步)——
写死#e0554d这类调色板值的后果是「换主题后柱子还是蓝橙绿」,用户一定会提。 - 图表标签字号小(~12.5px),图标选轮廓简单的;怀疑 emoji/图标"渲染成方框"时,
先放大到 24px+ 渲染同一批字符对比再决定(曾把 🚚🆕🔀 误判为字体缺失,放大后完全正常)。
第 5.9 步:自定义组件的配色必须「主题化」(写死颜色 = 埋雷)
症状:用户换了主题(尤其换成风格主题 / 暗色),工作台的骨架、卡片、文字都变了,只有你自定义组件的图表还是老颜色,
跟整套皮肤脱节。用户表述通常是「XX 板块的颜色要随主题变化」。
根因:模板每套主题只暴露一个主色 --primary(少数主题另有装饰色 --card-deco,可能是渐变),
没有现成的多色图表色板。图省事写死一套 PAL = ['#3f7fbf', ...],就等于绕开了主题系统。
做法:从主色实时派生色板,写成 CSS 变量挂到 :root,组件里一律 var() 引用。
/* 注入到自定义组件内部(首装执行一次;render 时与主题变化时各调一次 build) */
function build() {
const root = document.documentElement, dark = root.dataset.mode === 'dark';
const cs = getComputedStyle(root);
/* ⚠️ 浏览器对 CSS 变量取值不做颜色归一:可能拿到 '#5b5bd6' / 'rgb()' / 'hsl()' / 渐变,
必须自己写解析器(含「从渐变里取第一个色标」这条兜底)。 */
const base = parse(cs.getPropertyValue('--primary')) || [215, 62, 48]; // HSL 三元组
const deco = parse(cs.getPropertyValue('--card-deco'));
const H = base[0];
/* 红区主题(玫瑰粉 / 喵星人 H≥325 或 ≤50):色板单向往紫侧铺,否则 +48° 又绕回橙红,会与警示条撞色 */
const inRed = H >= 325 || H <= 50;
const offs = inRed ? [0, -30, -56, -82, -108, -15, -43, -69, -95]
: [0, 28, -28, 48, -48, 14, -14, 38, -38]; // 交错偏移:相邻档既有色相差又有明度差
const hues = offs.map(o => (H + o + 360) % 360);
/* 装饰色只在「与主色夹角 25°~100°」且明度可辨时采用(它常是浅粉浅蓝,直接当图表色会淡到看不见) */
/* 饱和度:max(base.sat, 44);但中性主题(sat < 8%,如石墨黑)反过来压到 16,走灰阶别硬拗成蓝调 */
const sat = base[1] < 8 ? 16 : Math.max(base[1] || 0, 44);
/* 感知亮度校正:同为 55%,黄绿看着发飘、蓝紫看着发闷 → 按色相分区 ±10 微调 */
const lumFix = h => (h >= 40 && h <= 90) ? -10 : (h > 90 && h < 175) ? -6 : (h >= 200 && h <= 292) ? 7 : 0;
const L = dark ? [64, 70, 58, 74, 52, 67, 61, 76, 55] : [47, 55, 41, 59, 36, 51, 44, 63, 39];
hues.forEach((h, i) => root.style.setProperty('--wbc-c' + i, hsl(h, sat, L[i] + lumFix(h))));
/* 警示色:锚定红;红区主题改琥珀 36°,否则与主色条撞色、警示失效 */
root.style.setProperty('--wbc-warn', inRed ? hsl(36, 88, dark ? 68 : 46) : hsl(6, Math.max(sat, 68), dark ? 72 : 56));
}
/* 主题 / 深浅色一变就重算 —— 组件里用的是 var(),所以条形图是「纯 CSS 自动变色」,不需要重渲染 DOM */
new MutationObserver(() => build()).observe(document.documentElement,
{ attributes: true, attributeFilter: ['data-theme', 'data-mode', 'class', 'style'] });
- 色相环游范围收在 ±48°:铺到 ±62° 会让紫色主题里冒出一根青绿柱,看着像串色;收窄后靠明度阶梯补可辨性。
- 语义色要锚定:异常 / 超期类固定警示色(不随色板跑),否则「红色的异常」会变成绿色,业务含义丢失。
但必须断言警示条与主色条色相差 > 30° —— 玫瑰粉主题下固定红会和主色撞成一片,这是硬指标。 - 顺手把这套规矩套到所有自定义组件(图形、进度条、时间轴、状态徽标…):只要带颜色,就走
var(--wbc-*)。 - 验证怎么写:用真实 UI 控件(
themeSelect的change、#modeBtn的click)切 4~5 套主题,
读getComputedStyle(fill).backgroundColor→ 转 HSL 断言色相落在预期区间(紫 200~300 / 绿 95~190 / 粉 300~360),
再断言深浅色切换后明度提升、弹窗标记同步。别只截一张图肉眼看。 - 提示:模板自带
chart组件用的是var(--primary),本来就跟随主题,无需处理;只有自定义组件需要这套。
第 5.10 步:导航升级为「左侧分类树」(用户说「板块分类 / 左侧显示 / 可以移动顺序」时)
触发语:板块要能自己排序、板块放到左边、按大类小类分类、小类我自己建。
最小侵入的做法:不动版块数据结构,只加一层「分组 + 顺序」的壳。
版块本体继续放在 layout.sections 扁平数组里(引擎的 section() / render() / 卡片拖拽全部原样复用),
新增 layout.cats 只描述「谁在哪个大类哪个小类、什么顺序」:
cats: [
{ id:'c1', name:'总览', icon:'📊', flat:true, subs:[{ id:'u1', name:'总览', secIds:['s1'] }] },
{ id:'c2', name:'Work', icon:'💼', flat:false, subs:[{ id:'u2', name:'待整理', secIds:['s2','s3'] }] }
]
flat:true的大类不再分小类(如「总览」):界面不渲染小类这一层,版块直接挂在subs[0]下。- 三层渲染:大类行 → 小类行 → 版块行;版块行点击切换(沿用
.tab类名,模板的 active 样式直接可用)。 - 重写
Layout.renderTabs()(名字不改,render()里的调用点不用动),把横向 tab 换成左侧树;
CSS 走main{display:flex} #tabs{flex:0 0 236px;position:sticky} #grid{flex:1;min-width:0},
窄屏(≤960px)回落成上下布局,否则手机上内容区被挤成一条缝。 - 配色一律
var(--*)(见 5.9):侧边栏底色--bg/ 分隔线--border/ 激活项--card+--primary,
这样换主题、切深浅色全部自动跟随。
必须实现的自愈逻辑(否则用户删版块后导航会留幽灵):
1. 没有 cats 就按默认结构建,并把已有版块按默认映射归位;
2. secIds 里指向已删版块的条目直接过滤掉;
3. 不在任何小类里的版块(孤儿)收进兜底小类,保证它在界面上一定找得到;
4. 兜底小类要选「非 flat 的 Work 类」,不是取第一个 —— 取第一个会把用户自建版块全塞进「总览」,导航立刻变乱。
5. 每个函数都基于「读当前布局 → 改 → 落盘 → render()」,异常时至少 render(),不留半改状态。
交互清单(缺一个都会被追问):点切换、↑ ↓ 按钮排序、拖拽排序(可跨小类 / 跨大类)、
✎ 改名、✕ 删除(有内容要 confirm)、+ 新建(大类 / 小类 / 版块)、» 折叠(折叠状态落 localStorage,重开保持)。
踩过的坑:
- 别在 <button> 里嵌 <button>(版块行内要放排序按钮时):HTML 解析器会自动闭合,结构当场错乱。
版块行用 <div class="tab">,内部按钮照常。
- ⚠️⚠️ 模板里有一条会废掉整页拖拽的全局样式,必须定向解除:spa 模板自带
<style id="__dm_no_drag_style__">* { -webkit-user-drag: none !important; }</style>,
draggable="true" 会被它整片盖掉(还带 !important)。任何原生拖拽功能都必须显式恢复:
#tabs [draggable="true"]{ -webkit-user-drag: element !important; },
只放开自己的容器,别动模板别处。排查顺序:先看 getComputedStyle(el).webkitUserDrag,再看 JS。
注意它只杀"拖动源",不杀 drop 目标 —— 症状是「按钮都点得动、就是拖不走」,极易误判成 handler 没绑上。
- ⚠️ 千万不能只用合成 DragEvent 验证拖拽:el.dispatchEvent(new DragEvent('dragstart'))
绕过浏览器拖拽管线(不查 -webkit-user-drag、不查 draggable),会给出假绿——
上一版 97 项断言全过、用户却完全拖不动。合成事件只能证明 handler 逻辑对,
证明不了用户能拖。必须走真实管线(见下「验证怎么写」④)。
这条同样适用于 hover / 聚焦 / 滚动等一切浏览器原生交互。
- 拖拽只认 data-kind + data-act 委托:dragstart/dragover/drop 全挂在容器上,
e.dataTransfer 可空(合成 DragEvent 恒为 null)→ 所有 dataTransfer 调用都要 if (e.dataTransfer) 兜住。
- 落点判定要做两级兜底:只认 .sb-cat-hd / .sb-sub-hd 那一条窄标题,落在区块空白处全判无效,
用户体感就是"怎么拖都不进去"。→ 先精确到行(定插入前/后),再退到容器
(.sb-sub → 小类末尾、.sb-cat → 大类末尾),并给大面积反馈(整块虚线描边 + 半透明底)。
- 拖进折叠区必须自动展开:数据挪对了但界面没变化 = 用户认为拖拽失败。
落位后先清目标大类/小类的折叠标记(revealBy()),再 setActive + render,做到"拖过去立刻看到结果"。
- 拖拽中途 DOM 被重建会让 drop 永远不来:自动刷新若 innerHTML 整体重建侧边栏,
被拖的源元素一移除,浏览器静默取消拖拽,dragend 也不触发,界面卡在半透明。
→ ①拖拽期间置 dragBusy,renderTabs() 见到就跳过(记 tabsDirty,松手补一次);
②window 级 dragend / mouseup 兜底硬清理。
- 行内按钮会跟着行一起被拖走:从 ⬆⬇✎✕ 上按下也会发起拖拽,想点按钮变成拖走版块。
→ dragstart 先判 e.target.closest('.sb-ops'),命中就 preventDefault() 取消;
拖拽期间用 body.sb-dragging 把按钮隐藏(opacity:0 + pointer-events:none),既防误触也不挡落点。
- 折叠状态 key 不要二次加前缀:cat.id 已经是 c4,再拼 'c'+id 会存成 cc4,断言与真实值对不上。
- ▸ ▾ ⬆ ⬇ 这类符号在部分字体环境渲染成方框(emoji 变体 / 字形缺失):
折叠三角用 CSS 画(border-width:5px 0 5px 6px + rotate(90deg)),
排序箭头改用普通字符 ↑ ↓,别把关键交互压在 emoji 上。
- 测试构造旧布局时,自建版块 id 要避开默认号段(别用 s9):真实用户自建 id 是 s+Date.now(),
但测试里撞车会让「归类」断言串位,白排查半天。
- 版块排序要写进 cats,不是 sections 数组顺序:sections 的顺序与界面显示顺序无关,
改错地方会出现「排完序重开就还原」。
验证怎么写(这几条缺一不可):
① 侧边栏几何位置在内容区左侧(比 #grid 的 left 小);② 大类数量与名称、版块数与 sections 一致;
③ 点 ↓ 后顺序变化 且 reload 后仍保持;
④ 拖拽必须走 Chrome 原生管线(合成事件会假绿):开 page.setDragInterception(true) 后
不能只靠 mouse.down/move/up——页面收不到 dragenter/dragover/drop,只收到 dragstart。
正确写法:
js
await page.setDragInterception(true);
const data = await page.mouse.drag(start, target); // 只用来拿 drag data
await page.mouse.dragEnter(target, data);
await page.mouse.dragOver(target, data); // 测落点前后半区就换这里的坐标
await page.mouse.drop(target, data);
await page.mouse.up();
await page.setDragInterception(false); // 验完必须关,否则后续鼠标操作失真
断言四件事:落位正确 + 目标是大类时自动展开并激活 + reload 后仍在 + 无残留高亮/卡住状态;
外加一条能力探测:getComputedStyle(el).webkitUserDrag === 'element'(防全局样式再次掐死);
建议在生成的项目里常留一份 dragtest.js(独立脚本,只跑拖拽,选择器按本项目改),
与 verify.js 分开:拖拽这条一旦被合成事件污染就整体失效,独立脚本更好守。
⑤ 新建小类 / 新建版块 / 改名 / 删除都断言落盘(读 localStorage 而不是只看 DOM);
⑥ 删除后断言无幽灵条目;⑦ 折叠后 reload 仍折叠;⑧ 切主题断言侧边栏激活项颜色变化(证明没写死色值)。
第 5.11 步:「清单类数据板块」(在研系统进度这类需求)
用户说「把我的 XX 清单放到工作台展示 / 更新」时(系统进度、项目清单、资产台账……),套这个模式:
- 数据一个集合:
Data['<清单名>']数组,字段自由;初值由构建脚本扫描/整理得出,写在tpl<名>常量里。 - 种子合并策略(关键):初值写入用「只补缺失条目」(按 id 比对),已存在的一律保留页面版本 ——
用户的页面内编辑(进度/状态)不被重建冲掉;想加新条目走构建、想改旧条目在页面点 ✎。
直接put()只对首装生效,两种通道会打架,别偷懒。 - 展示:顶部 3–4 个统计块 + 每条一行(图标 + 名称 + 徽章 + 进度条 + 摘要字段);
点行展开明细(里程碑 chips + 可勾选待办 + 位置/备注);✎弹表单就地编辑。 - 状态色跟主题:把「状态→色板槽位」的映射写进 ThemePal 的
build()(:root上挂--<前缀>-s0..N),
组件只用var()—— 换主题 / 切深浅色随 MutationObserver 自动刷新。 - 升级闸门配套:
- 新版块进 sections、新小类进 cats(LAYOUT_V + 1);
- 默认小类要按名字增补进老用户的分类树(ensureCats 里加一步:只认领「尚无归属」的版块,
孤儿兜底放它后面跑)—— 否则老用户看不到新小类,新版块还会落错位置;
-PREV_DEFAULT_IDS更新为上一版(刚被取代那版)的默认卡全集 —— 填成本版,
本版新卡会在升级瞬间被误判成"用户删过"而永不补回(2026-09-22 在采购订单工作台修正过这条误导注释);
验证要点(真实点击,参考采购订单工作台 verify.js 场景 7):
行数与统计卡数字对配置;点行展开;勾选待办落 localStorage;✎ 保存后 DOM + localStorage 双更新、reload 仍在;
状态色变量随主题切换变化。⚠️ 三个坑:<input type=range step=5> 设 88 会被吸附成 90(断言值必须是 step 倍数);
测试构造的旧布局必须与 PREV_DEFAULT_IDS 同代次(否则中间版本的卡被误记墓碑);
断言期望值一律从 build-summary.json 推导,不写死数字。
第 5.12 步:金额口径与「可搜索汇总图」(用户说「不含 XX / 只看最新 N 个 / 能筛选」时)
用户对图表提口径("不取已取消"、"不含测试数据")或筛选("能搜索"、"只看最新 10 个")时,套这个模式:
- 口径 = 派生数据集,不是 filter。模板组件里只有 stat / kpi 走
DataStore.filtered(认 filter 参数);
rank / line / chart 的 render 里是Data.get(dataRef),filter 根本不被读——加了静默失效。
正确做法:在计算核心(如 compute.js 的 buildAll)产出派生集合(如alive= 全量 − 排除项),
种子与实时刷新两处都写入 Data,各卡换dataRef指过去。改数据集合必须SEED_V + 1
(种子只在__seedv落后时整体替换;只 +LAYOUT_V 老用户口径不生效)。 - 口径写在脸上:卡片标题注明「不含取消」这类字样;图卡底部加动态口径提示
(如「已剔除取消单 102 张 · $565,352」——从数据现算,不写死数字,实时刷新自动跟)。 - 「最新 N / TOP N + 其他归并」:barchart 加
top+sort: latest(按各组最新日期字段降序,
日期字符串 YYYY-MM-DD 直接 localeCompare 即时间序);余下组归并成「其他 N 个项目」一行(可点开,明细是并集)。
⚠️ 聚合行绝不参与条宽基准取 max——它比个体行大 1~2 个数量级,会把所有个体条挤成细线
(定宽 100% + 斜体淡化即可)。与踩坑「表尾合计行混进聚合」同源:聚合行与明细行永远分开处理。 - 卡内搜索框:输入词存模块级对象(Layout.render 会重建 innerHTML,重绘后要还原);
input事件只重绘列表容器、不动输入框,否则连续打字丢焦点;搜索态不限条数(找得到才有意义),
清空恢复 top 限制。搜索框用type=text,别让「checkbox 计数」类断言误伤。 - 验证口径:三重对账——图条 data-v 之和 ≈ 派生集合合计;全量 − 排除 = 派生;排除确实生效(数量变小);
排序口径用源数据同规则推导首行比对。全部从 localStorage 数据推导,不写死金额。
第 5.13 步:给已有卡片加一个新交互(用户说"筛选加个下拉/多选/点击选择"时)
⚠️ 本步最大的坑不是交互本身,而是新交互怎么到老用户手里。布局闸门是「只增不减 + 墓碑」:
if (haveIds.has(dw.id) || removed.indexOf(dw.id) >= 0) return; —— 只补缺卡,不会把新配置字段并进
用户已存的卡片配置。所以给已有卡片加 select: true 这类新字段,老用户浏览器里控件永不出现,且不留报错。
- 优先挂靠已有字段:能复用已有配置(如
search:true)驱动新交互就复用 —— 老用户升级即生效。
复用不改变数据口径 / 卡片结构,LAYOUT_V/SEED_V都不用动(乱升可能触发"用户删过"误判)。
确实必须新增字段时,要同时写迁移逻辑,并补一条「老布局升级后新控件可用」的断言。 - 筛选模型用「集合 ∩ 文本」而不是二选一:勾选集合(空 = 全部)与卡内搜索词取交集;两者都空时保持
老行为(如 top N + 其他归并)—— 这样旧断言零回归,行为演进可叠加。 - 面板用「内嵌展开」不要绝对定位浮层:模板
.widget{ overflow:hidden }会把浮层裁掉;
内嵌面板不挑主题与特效模式(flip / physics / dock / depth),也无需 z-index 与定位跟随。 - 「点面板外面自动收起」要先列全挂到
body上的子交互:明细弹窗 / 菜单 / toast 都在卡片之外,
但属于本卡交互,必须排除(if (t.closest('.wbc-mask')) return;),否则"勾选 → 点条看明细 → 回来"
面板就没了。这类 bug 只在「先弹窗、后点按钮」的链路里出现,单控件断言永远发现不了。 - 状态存模块级会话对象(与
findState同性质:{ sel: [], q: '', open: false }挂pickState[cardId]):
属于浏览状态,不写用户数据、不进备份,所以也不需要动版本闸门。要在文档里向用户交代
「勾选刷新后会回到默认」——别让它看起来像丢了设置。 - 勾选态独立排序:显式选中时按金额降序(对比的自然读法),不沿用
sort:latest;条宽基准只取所选。 - 验证要点(一律真实交互,puppeteer-core 场景参见采购订单工作台 verify.js 场景 1.2):
真实点按钮展开 → 真实点 checkbox 勾选 → 断言图上只剩所选、逐条金额/单量对账(期望值从 localStorage 推导)
→ 徽标与计数提示同步 → 选中态点条仍能弹明细且面板保持展开 → 全选 / 清空 / 下拉内搜索各一条
→ 收起后恢复默认视图(给后续依赖默认态的断言铺路)。
⚠️ 两个实操坑:puppeteer 清空输入框不要靠click({clickCount:3})三连击(实测只删 1 个字符),
要逐字keyboard.press('Backspace');render(el)的el是.w-body不是.widget整卡
(模板def.render(box.querySelector('.w-body'), merged)),断言取dataset要从.w-body取,
否则拿到undefined。容器型断言取层口诀:标题.w-head/ 内容与 dataset.w-body/ 整卡.widget。
第 6 步:对话式定制迭代(双通道)
通道 A · 用户自助:告诉用户右上角 ⚙️ 面板里可 ①可视化配置加/改 widget(集合/字段/统计方式全下拉,不写 JSON)②◀▶ 调宽度、拖拽换位、+版块 ③导入数据后点「生成视图」自动出一套分类统计 ④卡片标题栏的 📥 就地换数(覆盖 / 追加,只换数据、卡片配置不动)⑤要精细控制再改布局 JSON。这类需求不用改代码。
通道 B · agent 改配置:用户提"加个 XX""把统计卡换成饼图""标题改成 YY"时,读 references/customization.md 按分类处理——优先改 DEFAULTS.layout 或布局 JSON,其次改 widget 配置,最后才改引擎。纪律:改前先读现状文件、改后必验证、已发布的工作台改完自动重新发布。
资源清单
references/role-templates/— 岗位模板库(R1 主体资产,48 套):24 个岗位各 1 主模板 + 1 备选模板,每套 8 项契约(对标来源 / 适用场景 / 继承范式 / 版块骨架 / 指标覆盖 / 首出主题 / 反模式 + 模板名)。README.md— 契约定义 + 主题 id 速查(50 套)+ 三层资产结构与使用规则,动模板前先读它functional.md— 12 个职能岗(产品 / 研发 / 项目经理 / 销售 / 市场运营 / 客户成功客服 / 财务 / HR / 采购供应链 / 数据分析 / 行政助理 / 教师培训)industry.md— 12 个行业岗(律师 / 医护 / 电商运营 / 内容创作者 / 创业者 / 学生 / 顾问自由职业 / 物流仓储 / 生产制造 / 房产经纪 / 投融资行研 / 政府文秘),末尾附来源可信度说明与涉密提示references/blueprints.md— 结构范式 A/B(2 套,讲「骨架怎么搭」):经营驾驶舱(看数)与管线作战室(看事),含对标来源、结构图、完整sections配置 + 种子数据、红黄绿阈值与行动、反模式清单。生成时按岗位模板的「继承范式」字段取用,不要再联网调研references/scenarios.md— 通用 Widget 清单(10 种)+ 看板设计准则 + 职能岗预置(R1–R12)+ 行业岗速配表 + 组合规则。已降级为兜底:命中岗位模板时不用它;只有岗位模板未覆盖时才回退到这里现场组装references/widgets-ext.md— 更多 Widget 清单(8 种,R3 资产,持续增加中):kpilinefunnelstepsrankprogresstimelinestock,含「模板类型 → 需要哪些 widget」索引表、逐项字段规范与反模式、funnel/steps判别对照表、与「导入即统计」的对应关系、新增 widget 的标准流程。生成时按模板类型查表取用(通用 10 种见scenarios.md,合计 18 种)references/security.md— 安全基线(R6 单一真源):本地/线上两种运行模式的安全边界差异、数据敏感度分级(L0–L3)→ 形态决策表、全栈 13 项安全清单、单页线上 6 项清单、「链接即钥匙」风险模型与反模式。生成线上工作台前先读它references/data-first.md— 数据驱动建台(R8 单一真源):业务识别机制(锚点/佐证/负向三类证据)、画像 → 卡片的正负向规则、落地三档(内嵌 ≤500 行 / 样本 + 导入 / 只给结构)、与 R6 的红线联动、识别不出来时的兜底、反模式。用户带着数据来时先读它(识别规则表的机器实现是scripts/datagen.js的BIZ数组,本文档不重复定义)references/themes.md— 主题商店:分组货架 50 套(8 配色 + 12 风格 + 8 新锐 + 1 萌宠 + 10 热门IP + 5 流行趋势 + 6 开发者经典),含装饰层变量与岗位推荐references/effects.md— 特效目录:6 项特效的实现方式、性能指引、使用规则,默认全开 + 首装守卫(无障碍 / 触屏降级),⚡ 面板与主题同级的面板规格(状态徽标 / 3 组分类 / 全开全关 / 点外收起 / 开关必须是<label>才点得动),以及三项「默认开之后才暴露」的边界(flip 触发面、physics 与布局拖拽互斥、weather 不改明暗)references/customization.md— 定制指南:诉求分类处理 + 迭代铁律 + 常见坑assets/spa-template/index.html— 单页模板(唯一真源):Widget 引擎 + 布局配置 + 数据集合 + 主题商店 + 特效引擎,四层一体assets/fullstack-template/— 全栈模板:Express + better-sqlite3 + JWT +apiTable服务端 widget;public/app.html由脚本生成,勿手改引擎段scripts/sync_fullstack.js— 从单页模板同步引擎到全栈模板(node scripts/sync_fullstack.js);改完 spa-template 必须跑一次scripts/refresh_instances.js— 把已生成实例的引擎刷新到当前母本(node scripts/refresh_instances.js [--check] <实例目录|index.html> [...])。改引擎不会自动更新已生成的实例——不刷新就会出现「新 widget 在实例里根本不存在」而基于该实例的测试集体误报。判据是母本里的ENGINE_VERSION常量(改引擎必须同步提升它)。三条已踩过的坑:- 两种种子版式都要认:① 种子在「启动」标记之前(datagen 写法);② 种子被塞在启动段中间(老生成器把
(function seed(){})();接在Layout.initPanel();之后)。版式 ② 只能靠花括号配平找 IIFE 结尾,用「切到标记为止」会切成负区间 → 抽不出种子。 - 单个实例失败不拖停整批(早先异常直接冒泡,一个版式不认的老实例会把排在它后面的好实例全部搁置)。
- 刷新是破坏性就地改写,必须有往返比对兜底:断言
DEFAULTS与Data.load()刷新前后逐字节全等、零pageerror、widget 种类数到位。没有这层比对,一次抽错就是静默丢数据。 - 由脚本每次从母本重新生成的 fixture 实例(如
_mk_sec_instances.py产出的安全测试台)不需要刷新——它们天然就是当前版。 scripts/selftest.js— 引擎自检(node scripts/selftest.js <报告路径>):校验两个模板<script>语法 + 纯逻辑(倒计时节日解析)。改完引擎必跑scripts/validate_catalog.js— 目录校验(node scripts/validate_catalog.js):校验scenarios.md与blueprints.md里所有 ```js 配置块——结构/唯一 id/type/span 合计/filter 语法,并交叉核对配置引用的字段、filter 取值、看板分组是否与种子数据一致。改完 references 必跑scripts/security_check.js— 发布安全闸门(R6)(node scripts/security_check.js <产物目录> --runtime online):BLOCK/WARN 分级扫硬编码密钥、明文口令、演示账号、本机/内网地址、隐私数据、http 混合内容、noindex 缺失、全栈安全实现缺失。退出码非 0 即拦截发布scripts/datagen.js— 数据体检与建台蓝图(R8)(node scripts/datagen.js <数据文件...> [--json]):xlsx / CSV(含 GBK 嗅探)/ JSON / 分隔文本直读 → 字段画像 → 业务识别(18 类业务 × 锚点/佐证/负向打分)→ 输出命中的岗位与模板(主题/范式/对标来源自动从role-templates/提取)、可直接用的DEFAULTS+sections蓝图、落地档位与隐私列警告。
⚠️ 画像算法与引擎DataStore.profile()逐行同源,改一边必须改另一边(有逐列一致性测试兜底);业务识别规则的唯一真源是它的BIZ数组
架构速查
DEFAULTS ──► 布局配置 v3(sections 版块制 → localStorage: workbench_layout[__ns])
│ │
│ └─► Layout.render() 渲染当前版块,按 widgets[] 逐条调 WIDGETS[type].render()
│ 顶部 Tab 栏切版块,「+ 版块」自助新增;旧版 { widgets } 自动 normalize
│ ⚠️ WIDGETS 是数组 → 可 push 自定义类型(如「点图条弹明细」的 barchart);
│ 注册必须赶在首次 render() 之前,否则 def 找不到会静默跳过整张卡
├─► 数据集合(Data,localStorage: workbench_data_v1[__ns]) ← 多 widget 可用同一 dataRef
│ └─► DataStore:⚙️ 面板导入文件(xlsx 直读 / 文本嗅探 / 编码自动识别)、连接外部 URL
│ ├─ parseXlsx() zip+OOXML 直读 .xlsx(零第三方库,多工作表/日期/公式缓存值)
│ ├─ decodeText() 编码嗅探(UTF-8/UTF-16/GBK——中文 Excel 另存 CSV 是 GBK)
│ ├─ parseAny() 文本类万能嗅探解析(10+ 格式)
│ ├─ profile() 字段画像(每列类型/唯一值/空值率/数值范围)
│ ├─ suggest() 画像 → widget 配置(导入即统计的核心)
│ ├─ autoView() 导入完成自动追加视图到当前版块(R5)
│ ├─ chooseFile()/parseFile() 选文件→解析(面板导入与卡片内导入共用一条链路)
│ └─ importInto() 卡片内就地换数:覆盖/追加 + id 去重 + 缺列告警(R7)
│ └─► Bus 总线:任一 widget 写数据 → 全局重渲染(多视图自动同步;
│ 笔记类 silent 写入不触发,避免抢焦点)
├─► 主题(ThemeStore,localStorage: workbench_theme[__ns])——v3 全局主题:
│ 每套主题带 icon,apply() 写入 --card-icon,widget 标题图标 + 卡片
│ 右下角水印自动跟随(手机主题式);切主题触发 Layout.render() 同步图标
└─► 特效(EffectStore,localStorage: workbench_effects[__ns])
└─► 安全(Security,R6):runtime='online' 时注入 noindex + 面板安全条 + 一键清空本机数据
runtime='local' 时不介入
[__ns] = DEFAULTS.ns ? '__' + DEFAULTS.ns : ''(引擎里的 K() 助手)
—— 同源多工作台互不串台;留空 = 沿用旧 key,向后兼容
边界
- 不硬编码任何用户的个人路径、账号、密码;生成时询问或留占位符
- 不把任何真实业务数据写进示例种子;要发布线上时,内嵌数据一律用虚构值(闸门会扫)
- 不发布带 BLOCK 的产物:
security_check.js不通过就不上线,这是硬约束不是建议 - 不做「看起来安全」的假动作:口令必须真哈希、密钥必须真随机;不要为过闸门而写注释糊弄(闸门查的是实现)
- 邮件发送只走 Agent Mail 连接器,不内置 SMTP
- 不主动替用户发布到线上,除非用户明确要求或该项目已有发布授权
```