全屋智能 SAAS 平台 · 功能文档
单一功能事实源(Single Source of Truth)
0. 文档约定
- 本文档是平台全部功能的单一权威说明,后续任何功能新增 / 修改 / 下线都必须先更新本文档对应章节,并在文末 「5. 变更记录」 追加一条。
- 每次完成功能修改后,必须向用户展示本次新增 / 修改的功能点(摘要 + 影响范围)。
- 功能状态标记:
✅ 已上线·🚧 开发中·📋 计划中。
1. 产品与技术概况
| 项 | 说明 |
|---|---|
| 产品定位 | 全屋智能(米家生态)方案设计、报价、BOM、施工图与交付的 SaaS 平台 |
| 技术栈 | Flask + gunicorn + Nginx;MySQL 库 quyuzhineng;Python 虚拟环境 venv |
| 后台入口 | http://140.143.142.129/app(路由前缀 BASE='/app') |
| 公共首页 | http://140.143.142.129/(Nginx 直出静态页,含 L1–L4 情景动画) |
| 方案公开预览 | /share/<token>(客户只读链接) |
| 服务器 | 140.143.142.129,应用目录 /www/wwwroot/quyuzhineng/app |
| 部署方式 | gunicorn 主进程 kill -HUP 热重载;静态页直接覆盖 index.html |
| 生态约束 | 方案与文案仅使用米家生态(长期约定,不写华为 / HomeKit / Aqara 等) |
在线功能展示页:http://140.143.142.129/docs/(Nginx 直出静态页,仅 admin 可见)。由本文档经 gen_docs.py 渲染生成 docs/index.html 后部署,内容与本文档保持一致。
新人使用手册:http://140.143.142.129/guide/(Nginx 直出静态页,全员可见)。面向零基础新人的使用视角文档,含 8 步流程 + 9 张真实界面截图 + 按钮速查表 + 踩坑清单。与本文档(技术视角)互补。
0.1 两份文档的分工与同步机制
功能文档 /docs/ |
新人使用手册 /guide/ |
|
|---|---|---|
| 视角 | 技术口径:路由 / 数据表 / 接口 / 权限 | 使用视角:怎么点、填什么、会踩什么坑 |
| 来源 | 功能文档.md → gen_docs.py 渲染 | 手写 docs/guide/index.html |
| 可见范围 | 仅 admin(admin_only) | 全员 |
| 发布 | python3 gen_docs.py + scp docs/index.html | sh .deploy/sync_guide.sh |
同步机制(防「界面改了、文档没改」)
docs/guide/check_sync.py 是手册的同步自检脚本,扫 7 个方案设计相关模板(schemes / scheme_form / scheme_detail / plan / bom / quote / drawing)里的真实按钮文案,与手册交叉比对,报三类问题:
| 类型 | 含义 | 是否阻断发布 |
|---|---|---|
[缺失] | 界面有、手册没写 —— 新人会找不到 | 阻断(退出码 1) |
[失效] | 手册写了、界面已经没了 —— 新人会对着空气点 | 提示 |
[可能过期] | 模板 mtime 比手册新 —— 界面动过、手册没动 | 提示 |
[断图] | 手册 <img> 引用的截图文件不存在 | 阻断(退出码 1) |
维护约定:
- 改了方案设计相关模板,必须跑
python3 docs/guide/check_sync.py,按报告改手册。 - 发布只用
sh .deploy/sync_guide.sh—— 它先跑自检,通过才上传、改属主、逐条验证线上 200。不要手动 scp 绕过。 - 新增页面必须把模板加进
check_sync.py的SCAN_PAGES,否则该页面脱离监控;只有「取消 / 关闭 / 纯筛选」类才加进IGNORE。 - 界面变化大时重新截图,替换
docs/guide/shots/下同名文件。 - 技术口径改动走
功能文档.md→gen_docs.py→/docs/,两套都要更新才算完成。
2. 权限体系
2.1 角色(5 种)
admin 管理员 · manager 经理 · designer 设计师 · sales 销售 · viewer 访客(只读)
2.2 权限装饰器
| 装饰器 | 允许角色 | 用途 |
|---|---|---|
@login_required | 任意已登录 | 查看类页面 |
@staff_required | manager / designer / sales / admin(即非 viewer 的内部员工) | 业务操作(增删改) |
@admin_required | 仅 admin | 系统管理(用户、设备、模板等) |
| 无 | 匿名 | 登录页、公开分享页、公开接口 |
2.3 模块权限矩阵
| 模块 | 查看(login) | 操作(staff) | 管理(admin) | 公开 |
|---|---|---|---|---|
| 账号/权限(用户、客户、设备、场景、报价模板) | ✅ | — | ✅ | — |
| 方案设计 / 项目管理 / 财务 / 分享 | ✅ | ✅ | ✅ | — |
| 个人资料 | ✅(本人) | — | — | — |
方案公开预览 / 公共首页 / /api/scenes | — | — | — | ✅ |
操作审计 /app/audit | — | — | ✅ | — |
说明:设备分类查看、设备/场景查看为 login;其增删改均为 admin。客户删除、方案/项目/房间/设备/施工图为 staff。报价模板管理为 admin。
3. 功能模块清单
3.1 账号与权限 ✅
| 路由 | 权限 | 功能 |
|---|---|---|
/login /logout | 公开 | 登录 / 登出 |
/profile | login | 个人资料:显示名、手机号、修改自身密码 |
/users | admin | 员工列表(5 角色) |
/users/<uid>/update | admin | 编辑员工资料 / 角色 |
/users/<uid>/password | admin | 重置密码(前端二次确认弹窗) |
/users/<uid>/toggle | admin | 启用 / 停用(前端二次确认,停用后无法登录) |
/users/<uid>/delete | admin | 删除员工 |
3.2 客户管理 ✅
| 路由 | 权限 | 功能 |
|---|---|---|
/clients | login 查看 / staff 删除 | 客户列表,字段:名称、电话、地址、等级、预算、来源、状态、备注 |
/clients/delete/<cid> | staff(POST) | 删除客户 |
3.3 设备库 ✅
设备图片压缩(2026-09-10 新增) —— 解决「库存页刷新图片加载慢」。 - 现象与根因:缩略图只有 44×64px,但上传的是原图(实测 14 张合计 5.75MB, 单张最大 1489KB / 1338×1280),且<img>无懒加载、无尺寸占位,刷新时全量拉取。 - 方案:产品图上传时等比缩到 800px 以内 + 转 WebP(q82)(cos_client._maybe_compress); 列表<img>加loading="lazy" decoding="async" width="64" height="44"。 - 红线:户型图(folder='plans')严禁压缩。点位 x/y 百分比标注精度、SVG 控制图导出 (viewBox 按原图比例算)、数字孪生底图都依赖原图分辨率。故upload_image的压缩 默认关闭,必须由调用方显式传max_side;plans调用处已加注释锁死。 - 其它保护:GIF 跳过压缩(PIL 只取首帧会丢动画);压缩后反而更大则用原图(不做负优化); 保留 EXIF 方向(ImageOps.exif_transpose),否则手机直出图压缩后会躺倒。 - 存量迁移:.deploy/migrate_compress_products.py(DRY=1预演 /DRY=0执行)。 下载→压缩→上传新 key→更新products.image_url;原图对象保留不删,改回旧 URL 即可回退。 实测 5.75MB → 0.35MB(−94%),14/14 成功;户型图回归确认仍是 1541×503 PNG 原图。 - 注意:COS 数据万象(?imageMogr2/thumbnail/...)在本桶未开通,参数被忽略直接返回原图 (实测 Content-Length 与 Content-Type 均不变),所以不能靠「URL 加参数」零成本缩图,必须服务端压。
| 路由 | 权限 | 功能 |
|---|---|---|
/categories | login 查看 / admin 写 | 设备分类(支持父级,树形):改名、改挂上级、合并转移、删除 |
/categories/delete/<cid> | admin(POST) | 仅删空分类(挂设备/子分类时拒绝并提示转移) |
/categories/merge (新) | admin | 合并分类:设备整体改挂目标分类 + 子分类提升 + 删除源分类(事务保护) |
/products | login | 设备列表,支持三重筛选:关键词 q(名称/品牌/型号)、分类 cat、标签 tag 二次搜索(标签可点击跳转 ?tag=xxx) |
/products/form | admin | 新增 / 编辑设备 |
/products/delete/<pid> | admin(POST) | 删除设备 |
/products/import | admin | 链接采集导入(淘宝/京东等) |
/products/import_template | login | 下载导入模板 |
/products/upload (新) | admin | 设备图片即时上传(AJAX,返回 JSON {ok,url}),供粘贴 / 拖拽 / 选择三种入口复用 |
/drafts 及 save/list/delete | login 查看 / admin 写(delete 为 POST) | 采集草稿(product_drafts 待上架库存) |
设备图片上传(三种入口,2026-09-03 新增) 1. 粘贴:在设备表单页面任意位置按Ctrl/⌘+V粘贴截图即可。全局监听paste事件,仅在剪贴板含图片时接管并preventDefault,粘贴纯文本不受任何影响。截图本身无文件名,前端按 MIME 自动补全(如image/png→paste-<时间戳>.png)后构造File提交。 2. 拖拽:把图片文件拖到虚线区域(dragover高亮)释放即传。 3. 点击选择:点击区域或聚焦后按Enter/空格唤起文件选择框;选择后立即上传,不清空 input 值以便重复选同一文件。
>
三种入口统一走POST /app/products/upload,上传成功即回写隐藏字段image_url并显示预览与「已上传 ✓」提示,URL 在点「保存设备」时才落库。支持「移除图片」清空。校验:仅png/jpg/jpeg/gif/webp/bmp,单张 ≤ 8MB(超限前端直接拦截并提示实际大小)。上传过程区域置灰防重复提交,失败时展示后端返回的具体原因。仅上传不建库,未保存的图片会成为 COS 上的孤立对象(体积可忽略,不做回收)。
分类维护(改名 / 合并 / 删除保护,2026-09-03 新增) - 改名与改挂上级:列表每行「编辑」按钮把该分类回填到底部表单(edit_id+ 名称 + 上级),按钮变「更新分类」,可「取消编辑」。保存后所有引用该分类的设备自动生效(分类是按category_id外键关联,改名字段即可,无需逐个改设备)。编辑时上级下拉会自动禁用该项自身,防止自引用。 - 同级重名校验:categories.name无数据库唯一约束,改由应用层校验「同一parent_id下不得同名」,冲突时不写入并提示。 - 环检测:新增_descendant_ids()递归取子孙集合,校验「上级不能是自己或自己的子孙」「不能把父分类合并进自己的子分类」,避免形成环导致分类从列表消失。 - 合并分类(清理重复分类用):点「合并到…」展开行内面板,选目标分类 → 二次确认 → 一次性完成三件事:① 源分类下的设备全部UPDATE products SET category_id=目标;② 源分类的子分类parent_id提升到目标下(避免悬空);③ 删除源分类。整段包在conn.begin() / commit() / rollback()事务里,失败整体回滚。提示语会回报迁移设备数与提升子分类数。 - 删除保护:DELETE前先统计设备数与子分类数,非空分类一律拒绝删除,提示「下还有 N 台设备 和 M 个子分类,请先用『合并到…』转移」;只有 0 设备 0 子分类的空分类才允许删。删除按钮在有内容时置灰并在title里写明原因。 - 设备数列:列表新增「设备数」列,数字可点击,直接跳产品库?cat=<id>查看该分类下的设备,同时作为删除保护的依据。
设备字段(products 表):分类、名称、品牌、型号、单价(对外售价) price、采购价(成本) cost_price、协议 protocol、接入方式 access_mode、兼容说明、图片、状态、标签 tags(逗号分隔,如 指纹, 面容, 密码, nfc)、施工图属性(net_role 网络角色 / ap_cover AP 覆盖 / panel_type 面板类型 / panel_height 安装高度 / woodwork 木作预留)。
成本 / 售价双价格体系:price为对外售价(报价单、客户视图、公开分享用);cost_price为采购成本价(BOM 物料清单、报价毛利核算用)。cost_price默认 0,设备表单与批量 CSV 导入均支持填写(导入模板表头新增cost_price/采购价)。未填采购价的设备:BOM 对应行显示「—」并顶部提示补全,报价单不显示「设备毛利」行。设备表单「采购价」输入项带说明文案;草稿上架入口(/drafts上架)不填采购价,自动为 0。
通信协议(多选):表单中protocol由复选框 + 自由输入两段构成。 - 复选框预设常用协议:mesh2.0 / mesh1.0 / Wi-Fi / 蓝牙 Mesh / Zigbee / 蓝牙 / RS485 / Modbus / KNX / PLC / Thread / Matter。 - 下方「其他」输入框允许手输(逗号 / 空格 / 分号分隔),例如Zigbee 3.0、LoRa、CustomTest。 - 提交时合并两项并去重,保存为,分隔的字符串;编辑时反向回填(先精确 / 前缀匹配勾上预设项,未匹配项还原到「其他」框)。
3.4 智能面板场景(驱动公共首页)✅
| 路由 | 权限 | 功能 |
|---|---|---|
/scenes | login 查看 / admin 增删 | 场景列表(按 level L1–L4 分级) |
/scenes/form /scenes/delete/<sid> | admin(后者 POST) | 编辑 / 删除场景 |
/api/scenes | 公开 | 供公共首页读取 L1–L4 面板场景段落 |
scenes 表字段:level(L1–L4)、name、description、devices、sort_order、status。
公共首页「智能面板场景」段落由该接口数据覆盖内置文案;文案须保持米家视角。
3.5 方案设计(核心)✅
| 路由 | 权限 | 功能 |
|---|---|---|
/scheme | login | 方案列表 |
/scheme/form | staff | 新建 / 编辑方案(名称、等级 L1–L4、面积、客户、设计费率、人工费率、备注) |
/scheme/<sid> | login | 方案详情:房间与设备分布、汇总金额(设备/设计/人工/合计) |
/scheme/<sid>/room /room/delete/<rid> | staff(后者 POST) | 房间增 / 删 |
/scheme/<sid>/item /item/delete/<iid> | staff(后者 POST) | 设备增 / 删 |
/scheme/<sid>/plan | login 查看 / staff 写 | 户型图(智能施工图)与设备点位,含控制图(控制关系可视化与编辑) |
/scheme/<sid>/plan/upload | staff | 上传户型图(支持 DXF/DWG) |
/scheme/<sid>/plan/point 及 light/move/delete | staff | 点位新增 / 灯光参数 / 移动 / 删除 |
/scheme/<sid>/plan/autofill | staff | 点位按房间设备自动填充(已优化为单次 JOIN 查询) |
/scheme/<sid>/plan/link | staff(POST) | 建立控制关系(upsert;支持 mode_id>0 触发灯光模式) |
/scheme/<sid>/plan/link/delete/<lid> | staff(POST) | 删除控制关系 |
/scheme/<sid>/mode/save | staff(POST) | 灯光模式 upsert(mid 可空 = 新建),全量替换 items |
/scheme/<sid>/mode/<mid>/delete | staff(POST) | 删模式(级联清空 scheme_mode_items;引用此模式的 link 行保留但 mode_id 清零) |
/scheme/<sid>/twin | login 查看 | 数字孪生(2.5D 等轴测 + 场景联动 + 光晕,独立页面,与 plan.html 不互改) |
/scheme/<sid>/mode/data | login | 读取方案全部模式 + items(前端预览/校验用) |
/scheme/<sid>/share | staff(POST) | 保存分享设置 —— 每方案唯一链接,可设有效期 / 是否显示型号 / 勾选「重新生成」轮换 token |
/scheme/<sid>/apply-template | staff | 套用报价模板快速生成设备清单 |
/share/<token> | 公开 | 客户只读预览页(根路径,不带 /app 前缀;/app/share/<token> 作为别名同样可访问) |
/scheme/delete/<sid> | staff(POST) | 删除方案 |
方案分享(客户只读预览 · 每方案唯一链接)✅:
- 唯一性由数据库保证:
scheme_shares建UNIQUE KEY uk_scheme_share (scheme_id);启动时先跑一次去重(同方案多条只保留MAX(id))。因此一个方案在表里永远只有一条分享记录。 - 重复点击 = 更新,不是新增:
scheme_share_create()先查已有记录 —— 有则UPDATE(沿用原 token,只改有效期 / 显示型号),无则INSERT。所以发给客户的链接不会因为后台多次保存而失效。 - 需要作废旧链接时才轮换:分享卡里勾「重新生成(作废旧链接)」提交
regen=1,才生成新 token —— 提示语为「分享链接已重新生成(旧链接即时作废)」。未勾选时提示「分享设置已更新(链接保持不变)」。 - 界面显示完整绝对地址:
<code id="shareUrl">{{ public_base }}/share/{{ token }}</code>,public_base由app._public_base()取自request.url_root.rstrip('/')(即http://140.143.142.129),不写死域名,换域名后自动跟随。 - 复制按钮三级降级(关键):
navigator.clipboard只在安全上下文(HTTPS / localhost)可用,本站是公网 IP + HTTP,clipboard 为undefined,初版直接调用导致「点了没反应也没报错」。现按序降级:
1. navigator.clipboard.writeText()(仅当 window.isSecureContext 为真时尝试);
2. 隐藏 textarea + document.execCommand('copy')(兼容 HTTP 非安全上下文);
3. 都失败则自动 Range 选中链接文本,提示「自动复制失败,已为你选中,请按 Ctrl/Cmd + C」。
成功时提示「已复制:<完整链接>」并把链接明文回显,方便肉眼核对。
- 卡片位置:位于「套用报价模板」卡之后、「房间与设备分布」之前;「交付物归档」卡在页面最下方(见 §3.7.3)。
分享页呈现(标题 + 完整报价,2026-09-05 完整化)✅:
- 标题逻辑(共用
app._share_title(s, client)):优先「{客户名}的{等级}方案」,如「Rio.的L2方案」;
未绑客户时退回「{方案名}(等级)」,如「Rio.的智能方案(L2)」。后台预览与分享页共用同一函数,
避免两处各写一遍导致显示漂移(教训来自 2026-09-05 跨页验证时的实际发现)。
- OG 卡片:分享到微信 / 社媒时显示
og:title(如「Rio.的L2方案」)
+ og:description(如「110 ㎡ · L2 · 8 件设备 · 合计 ¥ 4747.13」),链接一眼能识别。
- 报价区完整化:设备小计 / 设计费 / 施工费 / 合计 + 大写金额
+ 报价日期 + 有效期 + 备注(含设备/设计/施工费用,不含硬装与税费,最终以合同为准)
+ 乙方署名(复用 doc_export._load_company(),个人 / 公司双措辞自动切换)。
- 默认值:分享表单「显示单价与费用汇总」勾选框默认勾选;
取消勾选用作销售节奏(先让客户认可设计、暂不谈价)。
- 存量数据修复:发现 1 条历史记录
show_price=0(上一轮默认不勾版期间创建),
一次性脚本 /tmp/fix_share_price.py 刷回 1。改代码必须改存量数据,否则用户感知不到。
方案详情页「就地绑定客户」✅:
- 动机:分享设置处的「未绑定客户」提示只告诉用户「绑定后会怎样」,
但没告诉去哪儿绑。点「编辑方案」要跳另一页面再回来,体验太绕。
- 实现:详情页顶部「方案信息」卡里把「客户」字段从纯文本改成就地可绑下拉(staff 可用):
- onchange 自动 POST 到新路由 scheme_bind_client;
- 无 JS 环境(<noscript>)显示「绑定」按钮兜底;
- 提交后回到详情页,顶部 flash「已绑定客户:xxx」。
- 新路由
POST /scheme/<sid>/bind-client(@staff_required):
- 只 UPDATE client_id + customer,不动其它字段(避免整行覆盖的教训);
- 同步客户表里的名称文本(与方案表单保存逻辑一致);
- 解绑时只清 client_id,保留手填的客户名称文本避免数据丢失;
- 已登记 AUDIT_ENDPOINT_LABELS(绑定/解绑方案客户),进审计。
- 提示文案更新:未绑客户时改为「在上方「客户」处绑定后会显示成『客户名的L2方案』」,
把用户指引到刚刚做的下拉上。
添加设备交互(方案详情页)✅:
- 「添加设备」表单位于「房间与设备分布」面板顶部;
- 交互顺序:先选「类型」(分类)→ 下拉仅列出该类型设备 → 输入标签即时过滤(前端
tags子串匹配)→ 选中设备 → 选房间 / 数量 / 备注 → 添加; - 设备数据经
{{ products|tojson }}注入前端,过滤纯前端实现。
方案列表页(/scheme)体验增强✅:
- 顶部 6 张摘要卡:方案总数 / 启用中 / 停用·草稿 / 总房间 / 总设备项 / 已绑定客户数(前端实时统计,随筛选联动)。
- 筛选工具条:名称/客户/编号实时搜索(输入框即时过滤)+ 状态分页签(全部/启用/停用)+ 等级分页签(全部/L1–L4)+ 排序(最近更新/编号/面积/名称),右侧显示「显示 N / 共 M 个方案」。
- 操作列重新组织(原按钮挤成一行溢出):拆为三段、各自可换行不挤压——
- 主入口:📝 设计(蓝)+ 📋 复制(按该方案结构新建);
- 工具:户型图 / BOM / 报价 / 水电(链接)+ 分享(POST 表单按钮,外观与相邻 chip 一致);
- 管理(仅 staff):编辑 + 删除(红色,二次确认后跳 /scheme/delete/<id>)。
- 列表项新增「方案编号」展示(monospace 小字,紧跟名称),行内「方案名称」可点击进详情。
- 纯前端增强(Jinja2 + 原生 JS),无后端改动,仅模板
schemes.html与base.html新增样式。
方案详情页(/scheme/<id>)房间概念分离✅:
- 问题:原「房间与设备分布」面板把「控制中枢/照明控制/传感感知/...」这些功能类和「客厅/主卧/...」真实房间混在一起展示,概念错位;选配向导自动写入的网络方案/目标场景也被当作房间。
- 数据改造:
scheme_rooms表新增kind字段(room=物理房间 /system=系统配置 /meta=元信息),历史数据按 name 批量打标(控制中枢/照明控制/...→ system;网络方案(地基)/目标场景→ meta;其余默认 room)。 - 展示拆三段卡片(顶部色带 banner + 标题 + 统计范围 + 数量):
- 🏠 物理房间:造价统计范围,含「+ 房间」(datalist 固定列表:玄关/客厅/餐厅/厨房/主卧/次卧/儿童房/书房/主卫/次卫/衣帽间/阳台/储物间/车库/影音室/茶室/健身房/其他)+「+ 添加设备」(房间下拉仅显示物理房间,系统/元信息不可在此添加)。
- ⚙️ 系统配置:造价统计范围,展示选配向导写入的全屋唯一子系统(中控/照明/传感/安防/暖通/影音/清洁),可调整/移除设备;非由设计师新增。
- 📋 方案元信息:不参与造价统计,展示网络方案 / 目标场景 / 备注等文字,纯展示。
- 后端改动:
- scheme_detail 路由按 kind 三分类传给模板;
- scheme_room_add 新增房间一律 kind='room'(防止误增系统/元信息);
- /bom 与 /quote 查询加 JOIN scheme_rooms sr ON si.room_id=sr.id WHERE sr.kind<>'meta'(双保险:元信息天然 product_id=0 已被排除,再加显式 kind 过滤)。
- 数据库迁移:
db.py启动迁移加ALTER TABLE scheme_rooms ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'room',并对已知系统名/元信息名做 UPDATE。
3.6 项目管理 ✅
| 路由 | 权限 | 功能 |
|---|---|---|
/projects | login | 项目列表(含阶段、客户、金额汇总) |
/project/form | staff | 新建 / 编辑项目(编号、名称、客户、阶段、预算、合同额、已付、负责人、起止日期) |
/project/<pid> | login | 项目详情:阶段进度、关联方案、财务 |
/project/<pid>/stage | staff | 更新阶段完成状态 |
/project/<pid>/scheme/link /scheme/unlink/<sid> | staff(后者 POST) | 关联 / 解绑方案 |
/project/<pid>/finance | staff | 财务(合同额 / 已付 / 回款) |
/project/delete/<pid> | staff(POST) | 删除项目 |
数据表:projects · project_stages(阶段进度)· project_schemes(项目-方案关联)。
3.7 报价与模板 ✅
| 路由 | 权限 | 功能 |
|---|---|---|
/quote-templates /quote-templates/delete/<tid> | admin | 报价模板库管理(预设标准方案,一键套用) |
/scheme/<sid>/apply-template | staff | 在方案设计中套用模板 |
/quote | login | 报价页(基于方案设备 + 费率生成) |
/quote/export/<fmt> /scheme/<sid>/export/<fmt> /contract/export/<fmt> /bom/export | login | 导出(报价单 / 方案 / 合同 / BOM,多格式) |
- 报价口径(售价视角):设备明细与「设备小计」使用
products.price(对外售价);在「设计服务费」「安装施工费」之外,当方案设备均填写采购价(cost_price>0)时,额外显示「采购成本(设备)」与「设备毛利」两行(毛利=设备售价小计 − 采购成本,并附毛利率);任一设备未填采购价则不显示毛利行。合计=设备小计 + 设计费 + 施工费(不含硬装/税费)。
3.7.0 计价口径:单一入口(三条报价路径共用)✅
客户看到整体报价共有三条路径,必须共用同一个计价入口 db.scheme_totals(sid):
| 路径 | 入口 | 面向 |
|---|---|---|
| 方案分享页 | /share/<token> → app.scheme_share_view | 客户自助打开 |
| 后台报价页 | /quote?scheme=<sid> → app.quote | 设计师 |
| 报价单 / 合同附件一 | doc_export.build(sid) → Word / PDF | 客户与签署 |
scheme_totals(sid) 返回 (设备小计, 设计费, 施工费, 合计),费率算法为
设计费 = 设备小计 × design_rate / 100、施工费 = 设备小计 × labor_rate / 100。
设备小计的取数口径由 db.BILLABLE_ROOM_JOIN 唯一定义:只统计实体房间(kind='room')
与子系统(kind='system')下的设备,排除 meta 容器(网络方案(地基)、目标场景
这类只放文字描述、不挂设备的元信息房间)。
历史问题:三条路径曾各写一条 SQL,只有后台报价页带了kind<>'meta'过滤。 一旦有设备挂到 meta 房间,客户拿到的报价单就会比设计师在后台看到的多钱,属于报价事故。 已收敛为db.scheme_totals单一入口,取设备明细时也必须带BILLABLE_ROOM_JOIN过滤, 否则会出现「清单里列了设备、合计里没算钱」。 新增任何展示金额的页面,直接调db.scheme_totals,不要自己写SUM。
3.7.1 最终交付物导出(方案册 / 报价单 / 合同,Word + PDF)✅
由 app/doc_export.py 统一生成,数据全部来自本地库,不依赖外部接口。
| 路由 | 权限 | 产物 |
|---|---|---|
/scheme/<sid>/export/<fmt> | login | 方案册(.docx / .pdf) |
/quote/export/<fmt>?scheme=<sid> | login | 报价单 |
/contract/export/<fmt>?scheme=<sid> | login | 服务协议 / 服务合同(取决于乙方主体类型) |
/bom/export | login | BOM(CSV) |
协议 / 合同(封面 + 双方信息表 + 7 章正文 + 签署页 + 附件一):
一、服务内容 / 二、协议金额(含大写)/ 三、付款方式(3.1 定金 30%、3.2 到货款 40%、3.3 尾款 30%、3.4 逾期责任 日千分之一、逾期 15 日可暂停或解除)/ 四、工期与验收(4.1–4.4)/ 五、双方责任(5.1 甲方 4 项、5.2 乙方 4 项,用(1)(2)(3)编号)/ 六、质保与售后(6.1 试用期、6.2 质保年限、6.3 上门费、6.4 耗材、6.5 责任划分)/ 七、其他约定(7.1–7.4)。末尾合并附件一《智能家装报价单》到同一份文件。
正文条款由 doc_export._contract_terms(data) 单一函数产出,Word 与 PDF 两条渲染链共用,杜绝两个分支各抄一遍造成的内容走样。
- 报价单:序号 / 产品名称 / 型号 / 单位 / 数量 / 单价 / 小计,含设计费、施工费、大写金额(中文大写,如「肆仟柒佰肆拾柒元壹角叁分」)。
- 方案册:封面 + 项目概况 + 设备配置(按房间分组)+ 费用汇总 + 备注说明。
- 编号体系:协议编号 / 合同编号、报价单号、方案册号统一取
schemes.code,格式QLZ-YYYYMMDD-NNN(当日递增)。历史方案已回填。页眉右侧显示为「协议编号:/合同编号:」(随主体类型切换)。 - 页眉页脚:页眉左侧品牌、右侧协议/合同编号;页脚「第 X 页 / 共 Y 页」。PDF 采用两次 build 先探测总页数再回填,封面页不印页眉页脚;Word 用
PAGE/NUMPAGES域。 - 下载文件名(与项目名挂钩):统一走
doc_export.doc_fname(data, kind, fmt),规则为
<方案名>_<类型>_<方案编号>.<ext>,例:Rio.的智能方案_服务协议_QLZ-20260815-002.pdf。
| 组成 | 取值 | 说明 | |
|---|---|---|---|
| 方案名 | schemes.name | 经 _safe_name() 清洗:剔除 `/ \ : * ? " < > | ` 与换行/制表,连续空白压成一个空格,去首尾空格与句点,超 40 字符截断;为空则整段省略 |
| 类型 | 服务协议 / 服务合同(随主体类型)· 报价单 · 方案册 | 前两者由 company.lbl_doc_long 提供 | |
| 方案编号 | schemes.code | 为空时回退 S%05d(按方案 id 补零) |
中文名按 RFC 5987 编码下发(filename*=UTF-8''…),并附 ASCII 兜底名:非 [A-Za-z0-9._-] 字符替换为 _ 后再把连续下划线压成一个(否则 Rio.的智能方案 会变成 Rio.___________,一个下划线一个汉字),结果形如 Rio._QLZ-20260815-002.pdf。注意:gunicorn 会拒绝含非 latin-1 字符的响应头,直接拼中文名会 502。
同一份文件名同时用于下载头、COS 对象 key 与 scheme_files.fname(见 §3.7.3),三处永远一致。
3.7.2 乙方主体类型:个人 / 公司(措辞自动切换)✅
同一套模板按 system_settings.provider_type 输出两套措辞,个人为默认(1=个人、2=公司):
| 位置 | 个人模式(provider_type=1) | 公司模式(provider_type=2) |
|---|---|---|
| 文档标题 | 全屋智能家居服务协议 | 全屋智能家居服务合同 |
| 乙方标签 | 乙方(服务方) | 乙方(施工方) |
| 主体名称 | 服务方姓名 / 工作室名称 | 公司全称 |
| 证件号 | 身份证号(留空则整行省略) | 税号 / 信用代码(必出) |
| 地址 | 联系地址(选填,留空则整行省略) | 公司地址(必出) |
| 签署 | 乙方(签字) | 乙方(签字 / 盖章) |
| 生效条款 | 「经双方签字后生效」 | 「经双方签字(盖章)后生效」 |
| 方案册署名 | 设计服务方 | 设计服务方 |
| 报价单页脚 | 服务方:<名称> / 联系人 / 电话 | 同左 |
| 文件名后缀 | 协议 | 合同 |
表格 3 列:1 列出位置(旧版用「位置」列 + 9 行,375 窄屏下 thead 总宽 399px 撑破整页;精简为 3 列后正常)。
- 实现:
doc_export._load_company()一次性返回kind与全部lbl_*标签;_contract_info()决定是否输出证件/地址行;_contract_terms()用doc变量(「协议」/「合同」)拼装条款,全篇不留硬编码「合同」二字。
3.7.3 交付物归档到对象存储(COS)✅
导出方案册 / 报价单 / 协议合同时自动同步存一份到腾讯云 COS,方案详情页可随时回看、下载、删除。
目录结构(桶内前缀可在系统设置调整,默认 deliverables):
deliverables/
└── QLZ-20260815-002/ ← 按方案编号分目录
├── Rio.的智能方案_服务协议_QLZ-20260815-002.pdf ← 个人模式(公司模式为「服务合同」)
├── Rio.的智能方案_服务协议_QLZ-20260815-002.docx
├── Rio.的智能方案_报价单_QLZ-20260815-002.pdf
├── Rio.的智能方案_报价单_QLZ-20260815-002.docx
├── Rio.的智能方案_方案册_QLZ-20260815-002.pdf
└── Rio.的智能方案_方案册_QLZ-20260815-002.docx
COS 对象 key 与下载文件名同源:_archive_key() = <前缀>/<方案编号>/<doc_fname(...)>,即直接用 §3.7.1 的那套命名,改一处即三处同步(下载头 / COS key / 索引表)。
| 路由 | 权限 | 功能 |
|---|---|---|
| 导出三路由(§3.7.1) | login | 产物下载的同时旁路上传 COS |
/scheme/<sid>/archive | staff(POST) | 批量归档:一次性生成 6 个产物(3 类 × PDF/Word)全部落 COS,不返回文件 |
/scheme/<sid>/file/<fid>/download | login | 下载归档文件(私有对象走服务端代理) |
/scheme/<sid>/file/<fid>/delete | staff(POST) | 删除归档:先删 COS 对象再删索引,COS 失败则不删索引(不留孤儿记录) |
关键设计:
- 幂等:
scheme_files以(scheme_id, kind, fmt)为唯一键,重复导出走ON DUPLICATE KEY UPDATE覆盖,桶里不会堆积历史版本。 - 归档是旁路:
_archive_export()全流程吞异常,COS 故障只 flash 提示,绝不阻断下载。 - 默认私有:交付物含客户姓名 / 电话 / 工程地址,默认
private,经/file/<fid>/download登录校验后由服务端代理下载,桶地址不暴露。可切public-read(此时直接 302 跳 COS 直链省带宽)。 - 目录前缀清洗:
re.sub(r'[^\w/\-]+', ...)—— 保留中英文 / 数字 /_ - /,关键是剔掉.杜绝../路径穿越;连续斜杠合并、首尾斜杠去掉;空值回退deliverables。中文目录名实测可用(如客户交付/2026)。 - 归档卡片位置:方案详情页最下方(
id="archiveCard",排在「房间与设备分布」卡之后)。页面上导出入口在顶部、归档回看在底部,动线是「改配置 → 顶部导出 → 底部核对归档」。 - 配置项:系统设置(§3.14)的
cos_archive_enabled/cos_export_prefix/cos_archive_acl。
坑(已修):COS SDK 的 StreamBody.read(chunk_size) 只返回单个分块(默认 1024 字节),用它实现代理下载会拿到被截断的文件。必须用 b''.join(body.get_stream(chunk_size=)) 拼接——它走 iter_content() 且能正确处理 Content-Encoding。
3.8 BOM(物料清单)✅
| 路由 | 权限 | 功能 |
|---|---|---|
/bom | login | BOM 列表(按方案汇总设备、数量、金额) |
/bom/export | login | BOM 导出 |
- BOM 口径(成本视角):改用
products.cost_price(采购价)核算,表头「单价」→「采购价」、合计行改名「采购总成本」。任一设备未填采购价时,对应行采购价显示「—」且页面顶部提示「请到设备库补全采购价」;CSV 导出表头同步为「采购价」。BOM 因此是采购侧成本清单,与报价单的售价侧形成明确区分(差额即设备毛利)。
3.9 智能施工图 ✅
- 入口:
/drawing(归属某方案),login查看。 - 四个标签页:
1. 水电预留:各设备强电/弱电/网络预留汇总 + 按房间交底清单(接入方式、施工备注)
2. 网络布线:AP 覆盖、网络角色(net_role)、点位布点
3. 开关面板:面板类型(panel_type)、安装高度(panel_height)
4. 木作灯光:木作预留(woodwork)、灯光点位
- 数据来源:设备库
products的施工图属性字段。
3.9.1 控制图(点位 + 控制关系可视化)✅ 已上线
- 入口:
/scheme/<sid>/plan,与户型图点位共用一页(同图叠加渲染),无需新页面。 - 目标:把方案中所有「开关」和它控制的「灯 / 窗帘 / 情景」关系在户型图上一笔画出来,便于客户和施工方一眼看懂"哪个按键管哪盏灯"。同时给施工方一份物理 vs 虚拟连线的清单。
- 数据模型(已部署):
- 表 scheme_point_links:id, drawing_id, switch_point_id, ctrl_point_id, link_type(physical/virtual), key_no(1-3), action(single/double/long), mode_id, created_at;UNIQUE KEY (switch_point_id, key_no, action, ctrl_point_id, mode_id) 防重复;idx_drawing / idx_ctrl / idx_mode 三个查询索引。
- 列 scheme_points.point_kind ∈ {switch, light, curtain, sensor, ap, ''} —— 标识点位类型。
- 点位类型派生(app.py
_derive_point_kind,顺序敏感,仅回填空值不覆盖人工设置):
1. 面板类型 ∈ {零火, 单火, KNX, 情景} → switch
2. 品类/型号含「灯 / 光源」→ light
3. 含「窗帘 / 开合帘 / 梦幻帘 / 香格里拉」→ curtain
4. 含「传感器 / 探测 / 人体 / 存在 / 门磁 / 水浸 / 烟感 / 温湿度」→ sensor
5. 含「无线AP / 面板AP / 吸顶AP / AP面板」→ ap
- 点位形状与颜色(plan.html
.pt-dot.k-*):
| 类型 | 图形 | 主色 |
|---|---|---|
开关 switch | 蓝色方块 | #2f7fd1 |
灯 light | 金色五角星 ★ | #e6b740 |
窗帘 curtain | 紫色三角 | #a472c8 |
传感器 sensor | 青色圆点 | #3fb6a8 |
AP ap | 灰色方块 | #7d8a99 |
- 连线渲染:
- 物理控制(physical,需布线):红色实线 2.2px,曼哈顿正交三折(L 型,避免线交叉混乱)。
- 虚拟控制(virtual,无线 / 场景触发):黄色虚线 dasharray 7 5,二次贝塞尔曲线(中间拱起,便于区分)。
- 两端各加 6px 实心圆点 + 命中区(透明 stroke-width 14)便于点击。
- 聚焦态:点击关系 → 加粗 3.2px + 内阴影 + 聚焦气泡「键号·动作 → 受控点」;点位层高亮(.pt.sel),其它点 dim(.pt.dim,opacity 0.4)。
- 灯线重叠 → 阶梯分散(2026-09-08 需求 S-2):同一开关引出的多条线原本完全重合(共用干道 + 中点拐弯),施工时根本看不清每根线管什么。改为「车道模型」:每条线出发先水平/垂直错开一条小「车道」(lane = fan × 9px,fan = (idx − (count−1)/2)),再走各自的横干或竖干(拐点 t 在 0.2~0.8 之间沿主方向分散),最后入户。单条线(
count=1)时 lane=0 退化为原路径,完全向后兼容。plan.html的linkPath(a,b,type,slot)+ 新增linkMid(a,b,type,slot)(中点徽标/气泡跟线走)+linkFan(slot)(统一参数推导)。renderLinks按lk.sw预统计fanCount / fanIdx,逐条算 slot 传入;模式自环 (isMode) 不参与。虚拟线通过弧高倍数(1 + fan × 0.35)区分,保持同侧不反向。验证:生产方案 2 的开5(61→88/89/90 三条物理线)d值两两不同、起点一致、终到三个虚拟灯位(88/89/90)各异;改前/改后几何对比页/tmp/fan_test.html直观可见。 - 串联 → 分组显示(2026-09-08 需求 S-3):同一开关的"键 + 动作"组合若控制多个点位(如「开5 键3 单击」同时控 灯3 + 灯4),原先在「控制关系」面板里只是 2 行平铺,看不出是"一组动作"。改为按
sw|k|a|t分组渲染:组头显示「键N·单击 → 控制 2 个(串联)」+ 类型标签,组内每个目标缩进列一行。视觉与施工单上都更清楚。renderLinkPanel内构groups[],模板加.kp-ghead与.kp-row.kp-sub。 - 画布缩放(zoom)工具栏(2026-09-09):点位密集、图比较挤的场景下,鼠标滚轮(围绕光标位置)+ 工具栏
− 100% + ⟲三键缩放,范围限制在 50%~300%(不能再小,否则看不清点位;不能再大,否则连线/点位变形失真)。到边界后对应按钮disabled灰显。还原 ⟲ 重置transform-origin:center center。
- HTML:canvas 内插入 #zoomToolbar(绝对定位左上角)+ #zoomWrap 包住所有可缩放内容(planImg / linkLayer / bubbleLayer / .pt)。glowToggle 留在 zoomWrap 外,避免被缩放。
- CSS:.zoom-wrap transform-origin:center center; transition:transform .15s ease; will-change:transform。工具栏 999px 圆角 pill,26×26 圆形按钮,font-variant-numeric:tabular-nums 让百分比数字不抖。
- JS:initZoom() IIFE,ZMIN=0.5, ZMAX=3.0,clamp 后赋值。canvas.wheel event 用 {passive: false} + preventDefault(),避免冒泡到页面滚动。
- 零影响现有逻辑:zoomWrap 与 canvas 同 layout 尺寸,.pt 的 left:X% / top:Y% 仍是相对百分比定位;renderLinks 的 toPx(p) = {x:p.x/100*cw, y:p.y/100*ch} 用的是 canvas.clientWidth/Height(不受 transform 影响),所有连线坐标计算不变。拖拽点位时鼠标坐标按 canvas 像素算百分比 = zoomWrap 内百分比——zoom 视觉变化不影响落点位置精度。
- canvas min-height:360px 兜底:图片加载失败时 canvas 至少 360px 高,工具栏不会溢出。
- 回归:8 项 Playwright 全过(初始 100% / 放大 4 次→207% / 缩小 3 次→58% / 极限放大 300% 且 + disabled / 极限缩小 50% 且 − disabled / 滚轮 3 次→133% / 滚轮 6 次→71% / 30 个点位全在 zoomWrap 内);30 路由 0 失败;dark 4 / light 8 持平。
- 串联 → 总线渲染(2026-09-08 需求 S-4):之前「开5 键3 单击 → 灯3 +灯4」在 canvas 上画成 2 条独立线(即使 S-2 已经分散),但用户给了一张参考图说明「串联」在施工图里应该是一根主干出去 + 分叉到各目标的总线(电气施工图惯例)。重写
renderLinks:
- 按 sw|k|a|t 分组(mid 不参与:模式触发统一走自环分支)。每组一个渲染分支:
- 模式组:1 条自环(多个 mid 合并;气泡列全部模式名「⚡A / ⚡B」)
- 单条组:保持原 linkPath 单线(lane 扇形仍生效)
- 串联组(N>=2):1 根主干(开关 A → 目标中心点 M)+ N 条支线(M → 各目标)。主干的拐点 t 用组的 slot(与同开关其他组分散),支线不扇形
- 分叉点用 .lk-hub 小实心圆(半径 2.4)标注视觉上的「主干在这里分叉」
- 支线样式 .lk.branch{stroke-width:1.6} 比主干 2.2 稍细;聚焦时 .lk.on.branch 加粗到 2.6 与主干同步
- 聚焦交互整组联动:点击主干 = 聚焦组内第一条 link 作为代表;点击任一支线 = 聚焦对应 link;点击任一都会让整组高亮 + 气泡列出全部目标
- 中点徽标/气泡放在主干中点(不再是每条支线一个),气泡内容「键N·动作 → 目标A / 目标B」
- 控制关系「建好/删掉」即见即所得 + 路由全面 L 形化(2026-09-09):
1. 局部刷新代替整页 reload(用户反馈 1:「控制模式设置完后会有没有线的情况,需要刷新才有」):createLink / createModeLink / 删除 link 三个回调不再 location.reload(),改为把新 link 推入(已存在则替换)LINKS 数组 → window.renderLinks() + renderLinkPanel()。图纸/模式/缩放等状态不丢,关系一建好线就出来。
2. 路由改为「只拐一次弯」的 L 形(用户反馈 2「线有交叉不好看」):原先「先错开车道 + 中间拐点」的 4 段折线在串联场景下大量内部交叉。改为经典 L 形(A → 拐点 → B,拐点贴在目标 x 或 y 上)。多条同向线自然叠成一根「主干」再分叉——既符合电气施工图惯例,也彻底消除同开关的内部交叉。linkPath / linkMid 物理线分支各砍一半。linkFan 简化:{lane, fan, perp},lane 仅供虚拟线弧高倍数使用。
3. 角度排序(保留以服务虚拟线):同一开关引出的多组线按目标方位角排序后分配 slot。L 形本身不需要车道,但排序让虚拟线的 fan 顺序有意义(弧高按序号缩放、拱向左右交替)。
4. 光晕 + 连线渲染互相隔离(顺手修复潜伏的 P0 bug):之前的 renderGlows 用 document.querySelectorAll('.pt') 取了点位,又用 canvas.insertBefore(g, pt) 插入——但 zoom 改造后 .pt 已不在 #canvas 的直接子节点下(在 #zoomWrap 里),每次 window.redraw() 都会抛 NotFoundError: insertBefore……更糟的是抛在 redraw() 头部,导致 renderLinks() 根本不被调用——这才是「没线要刷新才有」的真因。renderGlows 改用 canvas.querySelectorAll('.pt') + (pt.parentNode || canvas).insertBefore(g, pt);window.redraw() 两个渲染各自 try/catch,互不连累。
5. 尺寸自愈:renderLinks 在 cw||ch===0 时隔帧重试(最多 40 帧),并新增 ResizeObserver 监听 canvas 尺寸变化——户型图加载慢、侧栏折叠过渡中的「整批不画线」彻底消失。
6. 后端 lastrowid 修复:两处 INSERT ... ON DUPLICATE KEY UPDATE 加 id=LAST_INSERT_ID(id),否则走更新分支时 c.lastrowid 返回 0,前端局部刷新后这条线的 id=0 后续删除/聚焦全部失效。
验证(生产方案 2:21 条线、4 开关 × 3 组):
- 几何:同开关交叉 0(旧 4 段折线 = 2;新 L 形 = 0);总交叉 0~3(画布宽高比影响,非本路由可控)
- 端到端(自建自清,方案 2 / sw=56 / ct=88):新建 id=54 → 重复添加(同 sw/k/a/t)返回 id=54 ✓ → 切 virtual 返回 id=54 ✓ → 删除 ok ✓ → DB COUNT=0 ✓
- 浏览器:JS 0 错 / 0 警告;30 路由 0 失败;dark 4 / light 8 持平
- 截图:.workbuddy/pres/canvas_L.png / canvas_L_zoomed.png(zoom 207% 下 L 形干净利落,无相互穿插)
- 画布缩放改成「只工具栏按钮」+ 空白处拖动平移(2026-09-09 截图反馈):
- 不再响应 wheel/双指缩放:「不要双指放大 只能单击左上角的放大缩小」。canvas.wheel 只 preventDefault() 阻止页面滚动 + 阻止 trackpad pinch-to-zoom 误触,不再调 setScale;同时吃掉 Safari 的 gesturestart/change/end。#canvas 加 touch-action:none 兜底触控手势。
- 空白处按住左键拖动 = 整体平移(pan):在 canvas 上 pointerdown 时排除 .pt / .lk / .lk-bub / .pt-del / .zoom-toolbar / button / input / select / .glow / .band,命中空白才进入 pan 流程,move 累计 panX, panY,up 结束。canvas.style.cursor='grabbing' 拖动中提示;#canvas{cursor:grab} 静态暗示可拖。
- transform 改为 translate(panX, panY) scale(s):与原有 .zoom-wrap 同源。setScale 在设 transform-origin 百分比时减掉 panX/panY(光标锚点保持),否则先平移再缩放会让光标位置偏掉。⟲ 重置同时清零 pan。
- 不与点位拖拽冲突:pointerdown 在 .pt 时新 pan 直接 return,原有 canvas.pointerdown(1013 行)继续负责点位拖拽;两个 handler 互不抢事件。
- 去掉 .zoom-wrap 的 transition:transform .15s:pan 时 0.15s 过渡会变成肉眼可见的延迟/拖尾,干脆无过渡更跟手。
- 顺带清掉画布 inline cursor:crosshair(早期"点击添加点位"时期的残留),让 CSS 的 cursor:grab 真正生效。
验证(Playwright,生产方案 2):
- 滚轮 5 次后 scale=1, pan=(0,0) ✓(不再缩放)
- 工具栏 + → scale=1.2 ✓
- 空白处按住左键拖动 100,100 → pan=(100,100), cursor=grabbing ✓
- 拖点位中 pan 不变 ✓(不抢点位拖拽)
- ⟲ 重置 → scale=1, pan=(0,0) ✓
- touch-action:none 生效 ✓
- JS 0 错;30 路由 0 失败;dark 4 / light 8 持平
- 截图:.workbuddy/pres/zoom_pan.png(144% + 整体平移,连线/徽标都跟动)
- 画布 click 排除工具栏/按钮/表单/链接 + pan 完设 dragged=true(2026-09-09 弹窗反馈):
- 真因:工具栏 <div id="zoomToolbar"> 在 #canvas 内部,点 + - ⟲ 按钮时事件冒泡到 #canvas 的 click handler;该 handler 只排除了 .pt 和 #glowToggle,没排除按钮/工具栏——落到「点击画布空白处 → 放置已选设备」逻辑,因没选设备 alert「请先在右侧选择要放置的设备」。
- 修 1:canvas click 加一行 if (e.target.closest('.zoom-toolbar, button, .pt-del, input, select, textarea, label, a')) return; —— 一刀切,凡工具栏/按钮/表单/链接都不视为落点。
- 修 2:pan move 累计 > 4px 时设 dragged=true,避免 pan 完松手后浏览器派发的 click 走「加设备」流程。这其实是上一轮 pan 改造的尾巴——pan 完时没设 dragged,浏览器会自动派发 click,于是这个潜伏 bug 才显出来。
- 验证:点 + → scale=1.2 弹窗 [] ✓;pan 完 → pan=(120,80) 弹窗 [] ✓;真点空白 → 弹「请先在右侧选择要放置的设备」 ✓(该弹的还弹)。
- 修「拖动点位位置与鼠标不符」+「外接屏后点位视觉位置漂移」(2026-09-09 双 bug 反馈):
- 两个 bug 同根:canvas 有 min-height: 360px(zoom 改造时为工具栏兜底加的),与 img 高度解耦——当图自然高度 < 360 时,canvas 高度 = 360 但 img 只占上面 240px,下方 120px 空白。.pt 的 top:Y% 相对含空白的 canvas算,不是相对图。
- 拖动:用 canvas.getBoundingClientRect() 算百分比(含 1px border + min-height 空白),渲染用 top:Y% 相对 canvas。两点参考系不同 + canvas 含 border——视觉上鼠标 = 点位(看起来一致),但点位相对图会偏。
- 外接屏切回:canvas 宽度变 → img 宽度变(width:100% height:auto)→ img 高度可能跨过 360 边界(如旧屏 800 宽 → img 240,新屏 1600 宽 → img 480)。canvas 高度 = 360 不变(min-height 兜底),点位百分比 top:Y% 视觉位置突变。
- 修 1(aspect-ratio 锁高度):删 min-height: 360px → aspect-ratio: var(--ar, 4/3)。img onload 时 JS 写 --ar = naturalWidth / naturalHeight 到 canvas,canvas 高度严格 = 宽度 × (h/w) = img 显示高度。无论怎么变屏,点位视觉位置相对图永远一致。
- 修 2(rect 用 #planImg 不用 #canvas):canvas.getBoundingClientRect() 返回含 1px border的外框,而 .pt 定位的 zoomWrap 是 canvas 的内容区(不含 border)——1px border 误差在长距离拖动时放大。统一改用 (document.getElementById('planImg') || canvas).getBoundingClientRect()——img 本身没 border,rect 严格等于内容区,坐标系与 .pt 渲染完全一致。拖动 + 点击添加设备两处都改。
- 真因判定:bug 1「点击位置不符」其实是 bug 2 的另一个表现——拖动视觉跟手是因为用了同一个含空白的 canvas rect,鼠标位置 = 点位视觉位置(都偏),但用户感知是「拖到 A,位置却不对」。修复后视觉跟手 < 1.1px(亚像素)。
验证(Playwright,生产方案 2):
- 100% 拖到 img(200,100) → 视觉中心偏差 X=0, Y=-0.55px ✓(亚像素)
- 150% 缩放后拖动 → 偏差 X=-0.007, Y=-0.66px ✓
- 模拟外接屏(card 拉满 100%)→ canvas 758×246, img 758×247(差 1px 渲染抗锯齿),aspect-ratio 锁住 ✓
- 跨屏拖到 img(500,200) → 偏差 X=0, Y=-1.09px ✓
- dataset.x=65.96 vs 视觉相对图 x=65.96 → 0 偏差 ✓
- dataset.y=80.84 vs 视觉相对图 y=80.40 → 0.44 偏差 ✓
- 30 路由 0 失败;dark 4 / light 8 持平
- 修「拖动点位位置与鼠标不符」+「外接屏后点位视觉位置漂移」(2026-09-09 双 bug 反馈):
- 两个 bug 同根:canvas 有 min-height: 360px(zoom 改造时为工具栏兜底加的),与 img 高度解耦——当图自然高度 < 360 时,canvas 高度 = 360 但 img 只占上面 240px,下方 120px 空白。.pt 的 top:Y% 相对含空白的 canvas算,不是相对图。
- 拖动:用 canvas.getBoundingClientRect() 算百分比(含 1px border + min-height 空白),渲染用 top:Y% 相对 canvas。两点参考系不同 + canvas 含 border——视觉上鼠标 = 点位(看起来一致),但点位相对图会偏。
- 外接屏切回:canvas 宽度变 → img 宽度变(width:100% height:auto)→ img 高度可能跨过 360 边界(如旧屏 800 宽 → img 240,新屏 1600 宽 → img 480)。canvas 高度 = 360 不变(min-height 兜底),点位百分比 top:Y% 视觉位置突变。
- 修 1(aspect-ratio 锁高度):删 min-height: 360px → aspect-ratio: var(--ar, 4/3)。img onload 时 JS 写 --ar = naturalWidth / naturalHeight 到 canvas,canvas 高度严格 = 宽度 × (h/w) = img 显示高度。无论怎么变屏,点位视觉位置相对图永远一致。
- 修 2(rect 用 #planImg 不用 #canvas):canvas.getBoundingClientRect() 返回含 1px border的外框,而 .pt 定位的 zoomWrap 是 canvas 的内容区(不含 border)——1px border 误差在长距离拖动时放大。统一改用 (document.getElementById('planImg') || canvas).getBoundingClientRect()——img 本身没 border,rect 严格等于内容区,坐标系与 .pt 渲染完全一致。拖动 + 点击添加设备两处都改。
- 真因判定:bug 1「点击位置不符」其实是 bug 2 的另一个表现——拖动视觉跟手是因为用了同一个含空白的 canvas rect,鼠标位置 = 点位视觉位置(都偏),但用户感知是「拖到 A,位置却不对」。修复后视觉跟手 < 1.1px(亚像素)。
验证(Playwright,生产方案 2):
- 100% 拖到 img(200,100) → 视觉中心偏差 X=0, Y=-0.55px ✓(亚像素)
- 150% 缩放后拖动 → 偏差 X=-0.007, Y=-0.66px ✓
- 模拟外接屏(card 拉满 100%)→ canvas 758×246, img 758×247(差 1px 渲染抗锯齿),aspect-ratio 锁住 ✓
- 跨屏拖到 img(500,200) → 偏差 X=0, Y=-1.09px ✓
- dataset.x=65.96 vs 视觉相对图 x=65.96 → 0 偏差 ✓
- dataset.y=80.84 vs 视觉相对图 y=80.40 → 0.44 偏差 ✓
- 30 路由 0 失败;dark 4 / light 8 持平
验证:生产方案 2 实际「开6(92)键3 单击 → 过道筒灯1(90)+过道筒灯2(91)」是 1 根主干 + 2 支线(trunk=1, branch=2, hubs=1, ends=2);另一组「开6 键1 单击 → 厨房射灯(89)」是单条线(trunk=1)。JS 语法 0 错。
- 控制关系 + 灯光模式 独立跨整行卡片(2026-09-08):原先两块挤在右侧 300px 控制面板底部,列被压成 30/42/1fr/46/48px,组头「控制 N 个(串联)」文字被换行成「控制 N / 个(串 / 联)」,目标名也截成「过道...」。改为把两块整体下放到户型图下方,与已标注点位同样作为
.card跨整行铺开;列宽放宽到 40/80/1fr/80/50px,给目标充足空间;组头那行文字加white-space:nowrap不再换行;linkList去掉max-height:230px(独立卡片自然有空间)。所有 ID 不变(modeView/modeCtrl/ctrlBox/keyNo/keyAct/linkType/selName/linkList/modeList/modeNew/modePreviewHint),JS 不需任何改动;原grid-col:1 / span 2是无效 CSS(应为grid-column),一并修正。 - 点位类型单一事实源(2026-09-08):
app.py顶部POINT_KINDS是唯一定义处,六项各含code / name / label / seq。下面这些全部自动派生,新增或改名类型只改这一处常量:
- POINT_SEQ_PREFIX(序号前缀 灯/开/帘/感/网/其,未分类回退「点」)
- POINT_KIND_CODES(服务端校验白名单,plan_point_light 非白名单值一律置空)
- POINT_KIND_LABELS(标签文字,含 ''→未分类,传给模板)
- 设置面板下拉(模板 {% for k in point_kinds %} 循环生成,不再是硬编码 7 个 <option>)
- 列表 kind-tag 文字、画布圆点 title
- JS const KIND_TXT = {{ kind_labels|tojson }};
- 「其它」点位类型支持自定义名(2026-09-08 需求 R):选「其它」时右侧追加输入框,自定义名(如「门锁」)会作为
point_kind_custom写入;序号按自定义名首字取(门1),标签显示完整自定义名(门锁)。改回其它值时清空。 - 交互流程:
1. 进入页面默认「浏览模式」:可拖拽点位、查看连线、点击连线聚焦。
2. 切到「控制模式」:先点一个开关点位(必为 switch),右侧控制关系面板出现「已选开关: <name>」+ 三个下拉「键号(1-3) / 动作(单击/双击/长按) / 线型(物理/虚拟)」;
3. 再点任意非开关点位(灯 / 窗帘 / 传感器)→ 自动调 POST /plan/link 建立关系(upsert 同 UNIQUE 键);
4. 已建关系列在 #linkList,右侧「删」按钮调 POST /plan/link/delete/<lid>。
- 接口校验(
plan_link_add):
- 两端必须同图同方案(JOIN scheme_drawings 验证);起点必须是 switch,终点不能是 switch(暂不支持开关控开关);
- 不通过校验返回 {ok:false, msg:...} 不写库。
- 点位不限量(虚拟灯位)(2026-09-08):真实设备按方案数量限流(
scheme_plan视图按_used[item_id] >= max(1, qty)过滤下拉,plan_point_add服务端placed >= qty拒绝插入并 flash 提示)。虚拟灯位(is_virtual=1)独立机制:scheme_item_id=0, product_id=0, room_id由用户自选,point_kind='light'强制写入;可无限添加,不进设备清单、不进 BOM / 报价,只参与控制关系与灯光模式。迁移:ALTER TABLE scheme_points ADD COLUMN is_virtual TINYINT NOT NULL DEFAULT 0。UI:控制面板顶部双 tab「真实设备 / 虚拟灯位」切换;点位列表里虚拟灯显示「虚拟」虚线徽标。 - 删除必须走 POST(2026-09-08 补前端):路由早已是
POST-only,但plan.html的删除 JS 仍发method:'GET'→ 405 被拒 →.finally照样删 DOM 节点,看起来删掉了刷新又回来。现已改 POST + 校验r.ok后location.reload()。 - 删除前留档(2026-09-08 新增):
plan_point_delete在DELETE前快照点位,写入审计detail(pid / drawing_id / label / note / scheme_item_id / product / x_pct / y_pct / point_kind / light_type / watt / is_virtual)。此前删除请求无表单,detail 恒为{},删完无法追溯或还原。 - 点位级联:
plan_point_delete删点位时同步DELETE FROM scheme_point_links WHERE switch_point_id=? OR ctrl_point_id=?,避免悬挂引用。 - 拖拽与点击冲突:
pointerdown重置dragged=false,位移 >0.4% 才算拖动;浏览模式下拖动跟随(window.redraw()替代原 resize 监听,重画连线)。 - 审计:新增
plan_link_add「新增控制关系」/plan_link_delete「删除控制关系」到AUDIT_ENDPOINT_LABELS;AUDIT_ID_KEYS加(lid, 控制关系)/(link_id, 控制关系)。 - 冒烟脚本
.deploy/smoke_p1.sh(13 步):登录 → 建ZZTEST-临时点位 → 渲染检查 → 建关系 → upsert → 非法起点 → 跨图纸 → 库内数据 → 刷新可见 → 删关系 → 级联删点位 → 审计日志 → 清理。v2 用临时点自建自清(v1 误用真实点位 21 把已删,复盘后改)。 - 「选择方案内设备」下拉改成可搜索 combobox(2026-09-08 需求 S-5):原先
<select id="deviceSel">是普通下拉,方案设备一多就要滚动翻找,对标注点位这种「一次定位一台」的低频操作来说效率太低。改为可输入过滤的 combobox:
- HTML:<div class="cb-wrap"><input id="deviceSelInput"> + <input type="hidden" id="deviceSel"> + <ul id="deviceList" class="cb-list"> + <script type="application/json" id="deviceOptions">[...]</script></div>。
- 数据:每条 item 含 id / label(展示用,如「卧室 · 空调伴侣(小米 2)×1」)/search(pname/brand/model/room_name 四段空格串,过滤用)/room/pname/brand/model/qty 五字段供模板布局。label 用 Jinja ~ 拼接(自动 int 转字符串,避免 + 触发 TypeError)。
- 交互:
- 聚焦展开全量选项;已选中态下再次聚焦 → 自动定位到已选项并 input.select()(全选文本,直接输入即替换)
- input 事件实时过滤;已选中态下继续输入只取 label 之后的新增字符当查询,之前的 label 不会污染关键词
- ArrowDown/Up 键盘高亮 + Enter 选中;Escape 收起;blur 延迟 150ms 收起(给 mousedown 抢先)
- mousedown(非 click)点选 <li, 配 preventDefault() 避免触发 blur` 收起导致点击失效
- 切「虚拟灯位」tab 调 window._deviceCbClear() 清空
- 无匹配 → 显示 <li class="cb-empty">无匹配项</li>;无设备 → input disabled + 占位文案
- 向下兼容:#deviceSel(hidden)保留,落点 JS 的 deviceSel.value 完全不用改;所有现有落点 / 删除 / 编辑路由不变。
- CSS:.cb-wrap position:relative(注意:不能嵌套 <style> 标签,否则浏览器丢弃全部规则);.cb-list position:absolute;top:100%;left:0;right:0;z-index:20;单行 4 列 flex(房间 · 名称 · 品牌型号 · 数量×),.cb-active 蓝底白字。
- 控制图矢量导出(SVG)(2026-09-09 修复命名 + 可读性
✅):
- 入口:户型图页顶部「导出 SVG」按钮 → GET /scheme/<sid>/plan/export.svg(登录后下载附件)。
- 产物:纯服务端渲染的静态 SVG(户型图底图 + 点位圆点/编号 + 控制关系线 + 模式自环 + 图例 + 署名水印),不依赖浏览器渲染,可直接进 CAD / 施工图 / 客户交付。
- 文件名规范(与其它交付物统一 <方案名>_<类型>_<方案编号>.svg):例 Rio.的智能方案_控制图_QLZ-20260815-002.svg。采用 RFC 5987 filename*=UTF-8''<百分号编码> 让中文名在各浏览器正确显示;旧版是 <方案id>_<时间戳>.svg(如 2_202609091833.svg),与归档规范不符。
- 点位对齐:viewBox 宽度固定 1200,高度按户型图真实像素比例算(PIL 探测图尺寸,H = round(1200 * ih/iw)),不再写死 1200×800 + meet 缩放——旧版因等比留白导致纵向错位约 90px,点位跑到隔壁房间。
- 名字可读性:标签文字加 paint-order="stroke" + 白色描边 halo(stroke="#ffffff" stroke-width="3.4"),压在深色户型图上也清晰;图例下方垫一块半透明白底板。名字之间用向外螺旋扫描避让算法,密集点位也不重叠。
- 代码:plan_export.build();探测尺寸用 _probe_size()(带 Range 头只读前 256KB,失败回退完整下载);避让用 _layout_labels()(贪心 + 螺旋扫描)。
- 验证:.deploy/verify_svg_export.py(4 项全过:文件名 / 标签 halo / 名字不重叠 / 比例匹配)+ .deploy/verify_svg_e2e.py(app.test_client 真打路由,端到端 ALL PASS)。
3.9.2 灯光模式(场景联动)+ 按键动作表 ✅ 已上线
- 入口:控制图页
/scheme/<sid>/plan右侧「灯光模式(场景联动)」卡 + 模态弹窗。 - 目标:把 P1 的"一开关一灯"扩展到"一键触发一组灯的目标态"(开关某个灯 + 可选亮度%),同时把"开关能配什么动作"做成一张按键动作表,跟施工方对单时一目了然。
- 数据模型(已部署,幂等迁移):
- scheme_light_modes (id, scheme_id, name, sort_order, created_at);UNIQUE KEY (scheme_id, name) 模式名在方案内唯一。
- scheme_mode_items (id, mode_id, point_id, state, brightness);UNIQUE KEY (mode_id, point_id) 同一模式同一灯只一条。
- 「模式触发 link」复用 scheme_point_links:ctrl_point_id=0 且 mode_id>0 表示"这个开关的这个键/动作触发这个模式",强制为 virtual 线。
- 模式编辑模态:
- 按房间分组列出方案内所有 light 类点位(被引用的 sensor / curtain 不进模式);
- 每行 checkbox + 开/关 select + 亮度 number(仅开 + 灯才允许设亮度,关态亮度自动清 NULL);
- 名称 1–60 字;保存走全量替换(先 DELETE 再 executemany INSERT),保持简单。
- 模式预览:
- 右侧模式卡点 chip 进入预览态 → 图上 light 点位加 .preview-on 金色实心+光晕 / .preview-off 半透+光晕关;
- 提示行「正在预览模式「xxx」—— n 灯开,m 灯关。再点该模式或画布空白处退出。」;
- 切换时只是 DOM 渲染变化,不落库,浏览模式不受影响。
- 按键动作表(
renderLinkPanel,#linkList):
- 列:键位 | 动作 | 目标 | 类型 | 删除;
- 排序:先按键号升序,再按动作(单击→双击→长按)顺序;
- 模式目标用 ⚡金底文字(#8a5b12)特殊标记;
- 聚焦面板上某条连线时(focusLink)也能正确显示。
- 连线中点键位徽标(
.lk-bub-key,控制模式常驻,浏览模式聚焦时显示):
- 普通键:黑底白字 "键N·动作";
- 模式触发:金底白字 "⚡键N·动作";
- 模式触发的线在图上画成自环(linkPath 的 isMode 分支:开关点甩出 30px 再绕回),直观表达"这个键触发了一个场景";
- 浏览模式(非控制模式)下徽标隐去保持图面干净。
- 空键提示(
renderKeyHoles):当面板的"最高键位 > 1"且中间存在未配键位时,黄色提示框「⚠ 键 X 还没配动作,可以绑定到已有模式(上方下拉),空着交付时客户会问这个键干嘛的。」—— 末尾键位空缺(单键开关很常见)不提示。 - 未受控灯 / 空开关警告(
renderUncontrolledWarn):方案内light但link_count=0的点位列在「点位自检」卡,并提示"导出方案册前请补全";空链接开关同样提示。 - 接口校验(
plan_mode_save/plan_mode_delete/plan_link_add):
- mode/save:name 1–60 字、items 必须是 JSON 数组、mid>0 时校验属本方案;
- mode/<mid>/delete:校验属本方案;UPDATE scheme_point_links SET mode_id=0 WHERE mode_id=?(不删行,行级 ON DELETE 太重,且保留关系可见);
- plan/link 新增 mode_id>0 分支:开关必须存在且是 switch;模式必须属本方案;ON DUPLICATE KEY UPDATE link_type='virtual'。
- 审计:
plan_mode_save / plan_mode_delete / plan_link_add / plan_link_delete全部进AUDIT_ENDPOINT_LABELS;AUDIT_ID_KEYS补(mid, 灯光模式)/(mode_id, 灯光模式)/(lid, 控制关系)/(link_id, 控制关系);AUDIT_TARGET_TABLE补'灯光模式': ('scheme_light_modes', 'name')。 - 冒烟脚本
.deploy/smoke_p2.sh(12 步):登录 → 建ZZTEST-临时开关灯 → 新建模式 + 改状态 → 创建模式触发 link(验证 mode_id / link_type=virtual / ctrl=0)→ 重复提交不变行 → 非法 mode_id 拒绝 + 文案 → 起点非开关拒绝 → 参数不全拒绝 → GET 模式数据 → 删模式级联清 link.mode_id(行保留)→ 清理临时 → 审计日志。所有 step 绿色。
3.9.3 数字孪生(2.5D 等轴测可视化)✅ 已上线
- 入口:
/scheme/<sid>/twin,方案详情页顶部按钮;与plan.html共用同一批点位/链接/灯光模式数据,独立页面,不动plan.html的任何渲染或交互。 - 位置布局(关键修复):stage 不再写死尺寸,按户型图真实比例动态算(
fitStage:W=92%wrap,h 受 H*0.38 限制),floor 用object-fit:fill精确填满 stage,markLayer/glowLayer必须position:absolute; inset:0否则塌成 height:0——这是上一版点位排成斜线的根因之一。bottom:5%锚在 wrap 底部,3D 旋转从底部原点展开,避免 stage 被顶出 wrap。 - 场景预设(6 个,按房间编排,规避 light_type 多半为空的现实):
- 全亮 17/全关 0;会客 11(公共区 ceiling/pendant 100、其他 70,卧室全关);用餐 5(厨房 100、过道 50);观影 8(客厅 12、过道 20);起夜 8(过道 15、卧室 8)。
- 类型衰减:MAIN=[ceiling, pendant] 取房间基础值;AMBI=[spot, strip, downlight] 取 60%;未填 light_type 视为主灯(更接近真实设计师按点位走线的习惯);房间未匹配 → 退化兜底(仅对按房间场景生效,不污染全关/全亮)。
- 联动:库里
scheme_light_modes有自定义模式则渲染按钮,否则只用 6 个预设。开关点击:清空 → 该开关 LINKS 控制的目标灯亮(其它dimmed透明 0.32)。 - 验证:
.deploy/verify_twin_entry.py,24 项断言全绿(按钮存在/链接正确/场景差异化/拖拽改视角/无 JS 报错)。 - 已知:4/17 灯有真实瓦数,其余走
DEFAULT_WATT兜底;库里 0 个自定义灯光模式,所以 6 个预设场景就是当前实际可用场景。
3.10 公共首页(静态页)✅
文件:public/index.html → 部署到 /www/wwwroot/quyuzhineng/index.html(Nginx 直出,非 Flask,无构建工具)。
配图:public/img/*.webp(部署到站点根 /img/),共 5 张、344KB。
定位:L1 到 L4 分级选型工具页(决策辅助,不是纯营销落地页)。核心任务是帮用户在四个等级里选出适合的那个,再导向选配问卷。
| 区块 | 布局族 | 说明 | |
|---|---|---|---|
| 导航 | 单行 sticky | 64px,分级体验 / 智能选配 / 管理后台 | |
| Hero | 非对称 split(文 \ | 图) | 单一主 CTA「看看四级的差别」锚点下跳 |
| 等级选择器 | 4 列等宽卡片 | 静态写入 HTML,每张含序号 / 名称 / 定位 / 预算量级 / 适合人群 | |
| 等级详情 | 2 列(能力 \ | 设备) | 随等级切换,JS 渲染 |
| 经典场景 | 图 \ | 文 split | 场景图随等级切换(5 张实拍) |
| 面板场景 | auto-fill 卡片网格 | 内容可由后台 /app/api/scenes 覆盖 | |
| 四级对比 | 折叠表格(8 行) | 解决来回点击的记忆负担 | |
| 落地提醒 | 3 列文本块 | 网络地基 / 品牌上限 / 面板融合时机 | |
| 出口 CTA | 全宽 | 「开始智能选配」→ /quiz.html |
交互与状态
- 默认选中 L2(主流客群,与后台方案默认等级一致);原版默认 L1(租房档),与真实客群错配。
- URL hash 记忆:
#L3直达并可分享;hashchange监听;切换用history.replaceState不污染前进后退。 - 键盘导航:左右方向键在等级间切换,焦点跟随。
prefers-reduced-motion:动画降级为静态。- 深色模式:CSS 变量 +
prefers-color-scheme,全页统一,不按区块反转。
设计约束(改版时定下的规矩,别回退)
- 单一 accent:全页只用品牌蓝
#2f7fd1(--accent)+#1f66ad(--accent-strong,按钮底,保证白字 WCAG AA)。L1-L4 不再各自换色——原版是绿/蓝/紫/橙四套,切等级像进了另一个网站,且 L3 紫色正是 AI-purple。 - 零 em-dash(
—–):全局禁用,用逗号、句号、冒号替代。 - 生态口径:只提米家。原版 L4 写「米家 / 华为 / HomeKit / Aqara 统一管理」,违反项目长期约定,已改为「全宅统一调度」。
- 预算用量级不用精确数字:千元级 / 万元级 / 数万元 / 十万级,并标注「含设备不含施工,实际以方案报价为准」,避免伪造精确报价。
- CLS = 0:等级卡片静态写入 HTML(不靠 JS 填充),其余区块在首屏视口外,JS 填充不计位移。实测 CLS 0 / FCP 180ms / Load 268ms。
回滚点:index.html.bak(改版前的旧版,cp index.html.bak index.html 即可回退)。
跨页设计语言统一(2026-09-04 两轮同步):
- 设计令牌:
/quiz.html与public/index.html完全同源的:root(中性色 / 单一 accent#2f7fd1+#1f66ad/ 阴影 / ease)。新增--accent-strong/--on-accent/--success*/--danger*/--warn*/--neutral/--line-strong(专给表单控件用,与--line区分,对底色 ≥ 3:1 满足 WCAG 1.4.11)。灯具演示里的物理色单独 tokenize。 - 顶部导航同款(与首页
.nav完全相同):brand「全 +全屋智能」+ 三件套分级体验 / 智能选配 / 管理后台(智能选配 active)。<560px 自动隐藏「管理后台」。原← 返回体验首页按钮删除(被 nav 里的「分级体验」链接取代)。 - 圆角 scale 文档化为 4 档:
--r-card(16) /--r-ctl(11) /--r-sm(8) /--r-pill(999)。收敛了原本 7 种散乱值。圆形 (50%) / 细线 (2-3px) / 品牌方块 (3px) 不进体系。 - AA 对比度:主按钮(
--accent-strong底 +--on-accent字)5.90:1(浅)/ 8.20:1(深)。旧绿色按钮白字只有 3.35:1。 - 0 处硬编码颜色(除灯具演示物理色外);零 em/en dash(17 处范围短横改成「L1 到 L4」/「×1 到 2」);6 处中文半角直引号改直角引号「」。
真无障碍缺陷修复(验证脚本先抓到再去改):
- 键盘用户走不完向导 — 选项原本是
<div>+ onclick,tabindex=null,单选步没「下一步」。修法:tabindex="0"+role="radio|checkbox"+aria-checked+ 容器role="radiogroup"+ Enter/Space/方向键 +focusStep()换步后送回焦点。 - 表单边界 1.24:1(WCAG 1.4.11 需 ≥ 3:1)—— 加
--line-strong+:focus-visible焦点环 + 输入聚焦软晕。 :active零反馈 ——.btn:active{translateY(1px)}/.opt:active{scale(.985)}。.opt{transition:.2s}通配全属性 —— outline-color 都被动,焦点环 200ms 才「渐显」。改成显式列属性 + 按压位移单独.12s。
灯泡 SVG + 色温映射修复:💡 emoji 改内联 SVG + currentColor,hsl(var(--hue),100%,93%) 真变色。色温映射 bug:原 hue = 35 + (temp/100)*(210-35),temp=50 落在 hue=122 是绿色,客厅 demo 默认就是绿灯。改成 hue = 30 + (temp/100)*35(暖橙 → 暖黄),全程暖色域。
真实图片 + 文案自检:intro 步加 /img/scene-l2.webp(aspect-ratio:16/9 占位,CLS 仍 0);×按需 → · 数量按需;窗帘类目命名统一;已联网版 → 已连后台;装饰 emoji(🚪🛋️🏠🎬)删,✅ 换 SVG 勾。
验证脚本:.deploy/_verify_quiz.py(Playwright 58 项全过)+ .deploy/_shots_quiz.py(截图 + 性能)。可传 URL 跑任意环境。
第二轮(2026-09-04 同日,扩到四个页面):
/docs/(gen_docs.py)::root换公共令牌;--brand:#2f6df6(与另三个页不同的蓝)→--accent:#2f7fd1;删--ok等旧令牌;硬编码rgba(255,255,255,.9)/#27313c/#33415c/#f0f4f9/#fafbfc全部收编;code加overflow-wrap:anywhere(长路径 / 方法名不断行会撑破 375 宽);<860px下表格display:block;overflow-x:auto;max-width:100%(原本会溢出 394px)。/guide/(docs/guide/index.html):同样对齐;旧--ok:#2e9e5b(白字 3.35:1)换--success:#2e7d4f;10 处「页面名 — 内容摘要」改成「·」;必填星号#e2574c(3.68:1)换--danger(5.25:1);顶栏装饰.dot换成与首页同款的 26px 品牌标记「全」。发布走sh .deploy/sync_guide.sh(自检→scp→chown→验证),禁止手动 scp 绕过。/index.html(首页):原来只有.pk:focus-visible,链接和按钮是浏览器默认焦点环(1px / auto / rgb(0,95,204),在浅色卡片上几乎看不见)。补全局:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+@supports降级到:focus。- 新增
.deploy/_verify_docs.py(跨页一致性机器校验,90 项全过):浅色 / 深色令牌跨页一致;正文 + 链接对比度 ≥ 4.5:1;真键盘 Tab 出现自定义焦点环(断言收紧:必须是实线 ≥ 2px + 品牌色,不再放过浏览器默认环);prefers-reduced-motion把过渡压到 ≤ .01s;375 窄屏无横向滚动;手册 9 张配图全部 200(urljoin相对页面 URL 而不是站点根);公共站点两页零 em/en dash。详见 §5.10。 - 踩坑:同一条消息里对同一文件发两次 Edit 会丢更新(read-modify-write 竞态,后写覆盖先写)。多次编辑同一文件必须串行或合并成一次 Python 脚本(原子)。
第三轮(2026-09-04 同日,扩到第五个页面——后台 /app/):
后台也用同一套令牌(详见 app/templates/base.html 与 §5 最新一条变更记录)。templates/base.html 是所有 29 个后台子模板的父模板,改一行全局生效(因此模板改动必须 systemctl restart,kill -HUP 不重编译 Jinja2)。重要约定:
- 17 个图标走
app.py注册的NAV_ICONS(Phosphor regular,stroke-width=1.5),侧栏与 dashboard 模块卡共用,不再各自内联 SVG。 - 空值占位走新 filter
| nil(注册在app.py),渲染<span class="nil">·</span>,配合 base.html 的.nil样式。表格空值<span class="muted">—</span>全部改成{{ x | nil }},破折号在子模板里集中消失。 - 所有表格外包
<div class="table-wrap">(横向滚动 + 统一卡片描边);card/room-card 内自动去重描边。drawing.html4 个内联房间卡 div 换class="room-card"。 - 移动端 480 关键断点:
.stats/.modgrid必须1fr 1fr(保持 2x2,不要退到1fr单列),顶栏隐藏搜索框让出空间给主 CTA。 _scheme_picker漏传key的根因:所有_xxx 通用辅助函数调render_template(..., k=v)时,模板里实际用到的每个变量都要从源头传入。
3.11 仪表盘 ✅
- 入口:
/(后台首页),login。 - 统计卡片带数字滚动动画与入场动画;已优化为单次多子查询聚合(消除 N+1)。
3.12 选配向导(公共首页引导选设备)✅ 已上线
- 目标:在公共首页 L1–L4 体验之后,用选择题引导用户循序渐进选出适合自家的智能设备,最终生成「简易版设备选择方案」(含网络方案、设备清单、目标场景、客户信息),并一键落地为后台方案草稿。
- 入口:
http://140.143.142.129/quiz.html(文件public/quiz.html,复用首页视觉风格,独立页,已接后端)。 - 顶部状态徽标:绿色
● 已联网版 · 提交后自动存入后台(替代旧 DEMO 角标),用户进页即知已接入后端、点「生成正式方案」即落库。 - 进度条可点击跳转:顶部 1–16 编号步进圆点对已访问过的步骤自动激活(绿色描边 + hover 放大),点击直接跳回该步并保留
history,再点「上一步」可回到当前进度;未访问的步骤保持中性灰,避免误跳出现空白。 - 底部按钮固定吸底:「上一步 / 下一步 / 生成正式方案」按钮区使用
position:sticky; bottom:0固定在可视区底部(带浅阴影 + 顶部 1px 分隔线),用户即便往下滚看长描述也不会随动消失。 - 「我要灯组」依赖关系:灯组是「智能灯 + 智能开关 无线模式」才有的玩法,UI 上默认 disabled,并显示 ⚠️ 提示「请先在上面勾选『智能灯 + 智能开关(无线模式)』」;勾上无线模式后自动启用,取消无线模式则自动取消灯组并再次 disable;下一步按钮按"至少勾一个搭配或灯组"判定可用。
- 响应式:
.combo-grid默认单列(避免手机/平板横屏下两张卡挤到 ~150px 把手机(在线)/色温/80%压成单字符竖排),仅 ≥1100px 桌面端才 2 列;≤900px 内 combo-card 灯/控制上下 stack(避免横排被挤)+ lamp 改横排(💡+状态文字,灯 56px),slider-row 允许换行;≤480px 进一步压缩灯到 48px、卡片 padding 收紧。 - 防横向滚动:手机/平板打开整个页面会左右滑动的根因是顶部进度条
.prog有 16 个flex:0 0 auto圆点(每 30px = 480px 固定),在 360px 屏溢出 120px → 整页横滑。修复:窄屏缩小圆点(≤900px 22px / ≤480px 18px)+ 收紧.prog/.pseg间距;并给.wrap加overflow-x:clip(不创建滚动容器、不影响底部 sticky 吸底)作为兜底,任何意外超宽元素都被裁掉。 收紧。 - 问卷结构(大类 → 小类,需要/不需要可跳过):
- ① 网络基础(4 档:完全不关心 / 仅刷视频 / 游戏零卡顿 / 电脑有线打游戏)→ 推荐路由器 / Mesh / AC+AP 方案
- ② 控制中枢(需要?→ 带屏中控 / 无屏网关 / 都要)
- ③ 照明控制(交互演示:三种搭配单选——智能灯+普通开关 / 普通灯+智能开关 / 智能灯+智能开关(无线模式)⭐推荐;含灯泡可视状态 + 墙开关 + 手机面板色温/亮度;末尾灯组玩法:客厅筒灯/灯带/主灯 + 全屋手机面板 + 场景按钮 + 动画)
- ④ 窗帘门窗(需要?→ 客厅 / 主卧 / 儿童房 / 书房 / 开窗器,可多选)
- ⑤ 传感感知(需要?→ 毫米波人在 / 门窗磁 / 温湿度光照 / 空气,可多选)
- ⑥ 安防(门锁 / 摄像头 / 燃气水浸烟感 / 红外幕帘,多选)
- ⑦ 暖通 HVAC(中央空调 / 风管机 / 普通壁挂空调 / 地暖 / 新风 / 面板融合,多选)
- ⑧ 影音(需要?→ 背景音乐 / 投影电视联动 / 智能音箱,多选)
- ⑨ 清洁(扫地机 需要/不需要)
- ⑩ 目标场景(L1–L4 场景多选)
- ⑪ 客户信息(称呼 / 电话 / 面积 / 类型 / 补充)
- 输出:估算等级(L1–L4 启发式)+ 网络方案 + 分类设备清单 + 场景清单 + 客户信息;支持生成正式方案(存后台) / 复制方案名 / 下载 .txt / 重新选配。
- 复制方案名:只复制「方案编号 + 方案名称」(如 QLZ-20260818-SU8U zzz的110㎡ 平层 智能家居方案(L3 全屋智能)),方便贴到客户档案/微信备注;不再复制整段方案文本。
- 打开后台方案:跳到 /app/scheme/<id>(后台方案详情页),按钮旁标注「仅管理员可见」并附「管理后台」链接,引导设计师登录后台调整。
- 后台调整与二次生成链路(已具备,§3.5 方案设计 / §3.6 项目 / §3.7 报价 / §3.8 BOM / §3.9 施工图 / §3.5 分享链接):
1. 管理员在 /app/scheme/<id> 直接编辑方案元信息、增删房间、增删设备(覆盖或补充选配向导写入的推荐项)。
2. 上传户型图 → 在图上点选设备点位(含灯位/灯型/瓦数/方向)→ 自动 / 手动画点位。
3. 一键生成 BOM 物料清单(/app/bom?scheme=<id>)、智能报价(/app/quote?scheme=<id>)、智能施工图(/app/drawing?scheme=<id>),二次生成指基于调整后的方案重新跑这 3 个模块。
4. 「分享给客户」生成 /share/<token> 公开只读链接(带有效期/显示模式),客户无需登录即可查看调整后的方案;这条链路是管理员与客户沟通的对外通道,区别于管理员自己用的 /app/ 后台。
- 后端对接(已上线):
- 新增公开接口 POST /app/quiz/submit(无需登录):接收问卷 JSON,按 quiz_build() 映射为分组/等级/网络/场景(逻辑与前端 buildResult 一致)。
- 客户:按「称呼 + 电话」去重,存在则复用 clients,否则新建(来源标记为「选配向导自助」)。
- 方案:写入 schemes(编号 QLZ-YYYYMMDD-XXXX、名称自动拼装、等级 L1–L4、面积、关联 client_id),每个设备大类建一条 scheme_rooms 记录,设备推荐文案写入 scheme_items.note(product_id=0,明细页回退显示 note)。
- scheme_rooms 与选配向导 1:1 对齐:除客户勾选的设备类(控制中枢 / 照明控制 / 窗帘门窗 / 传感感知 / 安防守护 / 暖通 HVAC / 影音娱乐 / 清洁电器)外,网络方案(地基) 作为最优先 room 写入、目标场景 作为最末 room 写入(一行汇总),保证后台「房间与设备分布」与选配向导结果页的 6 大段完全对应,设计师可直接在后台看到并增删/替换。
- 防滥用:蜜罐字段 company 命中即静默返回成功不落库;缺称呼且缺电话则返回 400。
- 明细页 scheme_detail 查询由 JOIN 改为 LEFT JOIN,无产品的推荐项也能正常显示(pname or note 回退,price or 0 防 NULL)。
- 流程改造(2026-08-19):
- contact 步直接落库:原流程在 contact 步仅把表单数据塞入 answers['__contact__'] 并跳到结果页,由结果页的"生成正式方案(存后台)"按钮二次触发 fetch /quiz/submit。改为 contact 步点击「保存方案 →」直接调用 fetch,成功后将 {scheme_id, code, name, url} 存入 window.__QLZ_SCHEME__ 并跳转结果页,按钮显示"保存中…"loading 态;失败保留按钮可重试。
- 结果页去后台入口:删除结果页"⬆ 生成正式方案(存后台)"按钮、"打开后台方案 →"链接、"🔒 仅管理员可见"段落、#submitResult 容器与 submitBtn.onclick 全部逻辑。改为顶部绿色 ✓「方案已保存」横幅(编号 + 名称 + 提示设计师后台会看到),用户自留路径仅保留「复制方案名 / 下载 .txt / 重新选配」。如 contact 步未提交(用户跳过),显示黄色 alert 提示返回提交或下载 .txt 自行留存。
- 多选题 0 选也能下一步:所有 type:'multi' 页(curtain_rooms / sensor_type / security / hvac / av_type / scenes)按钮默认 disabled 取消,移除 onclick 里 nextBtn.disabled = arr.length===0 的回写逻辑;语义对齐原 hint「可多选,不需要可不勾」。
- 文案打磨:删除第 2 步「智能开关改墙上的开关;智能灯泡直接换灯。二选一或都要。」;第 3 步问句「怎么搭配最聪明」→「哪种搭配更合适」;第 4 步移除「电动开窗器」项与「投影幕布」建议(不属于窗帘);第 5 步 hint「门窗/环境传感器做安防与自动」→「门窗 / 环境传感器做安防或自动控制」;第 10 步「门窗磁」→「门窗磁传感器」(label + 建议 + 落库映射均改)。
3.13 侧栏菜单层级 ✅ 已上线
入口:所有 /app/* 页面左侧固定 230px 侧栏(base.html),admin 视角 4 分组标题 + 12 可点菜单,业务员工(designer/sales/manager)视角 3 分组标题 + 7 可点菜单。分组标题不可点击,仅作视觉分组。
| 分组 | 菜单(→ URL) | 权限 |
|---|---|---|
| (横切,无分组标题) | 概览 → /app/ | login |
| 物料设备 | 设备库 /app/products · 分类管理 /app/categories · 采集入库 /app/drafts | admin |
| 场景方案 | 场景管理 /app/scenes · 方案设计 /app/scheme | 设备库类 admin,方案全员 |
| 项目交付 | 客户管理 /app/clients · 项目管理 /app/projects · 智能报价 /app/quote · 自动 BOM /app/bom · 智能施工图 /app/drawing | login |
| 员工 | 员工管理 /app/users · 系统设置 /app/settings · 操作审计 /app/audit | admin |
| 帮助 | 使用手册 /guide/ · 功能文档 /docs/(admin) | 手册全员,文档 admin |
- 业务逻辑层:
app.py:NAV数组每项带group字段(True分组标题 /False普通菜单),nav_for(active)透传group与active并按admin_only过滤。 - 新增字段:
desc(模块一句话说明,概览卡片用)、blank(True时侧栏target="_blank",用于站外/静态页)、doc(True表示「文档手册」,概览页从功能模块网格里分出来单独一块)。新增模块必须带desc,否则概览页卡片说明是空的。 - 模板层:
base.html在for n in nav循环里判断{% if n.group %}输出<div class="side-section">{{ n.name }}</div>,否则渲染原<a>(含blank判断)。 - 概览页(
dashboard.html)分两张卡:
- 功能模块:{% for n in nav if not n.group and not n.doc %} —— 跳过分组标题(否则渲染出空卡片)与文档手册,说明文字取 n.desc(早期版本是一长串 elif n.key==... 硬编码,已废弃)。
- 文档手册:{% set docs = nav | selectattr('doc') | list %} 单独成卡,用 .docgrid / .doc 紧凑样式(浅蓝底 + 左侧色条),与业务模块视觉区分;{% if docs %} 判空,非 admin 只有「使用手册」一张。
- 入场动画:doc 的 animation-delay 接在最后一个 mod 之后,两块依次淡入。
- 样式:
#0f1b2d深底 +.side-section浅灰小字(11px、letter-spacing 2px),首个分组免顶部分隔线;分组标题 + 后置菜单去掉多余 margin,自动对齐。 - 关键设计:分类管理/采集入库与设备库合并为同一组(物料设备 → 库存的"上游→分类→列表"闭环)。场景管理在「场景方案」组而非「物料设备」组(它驱动选配向导与方案,而非设备本身)。智能报价/BOM/施工图未下放到方案(保持独立菜单,避免一次重构过大)。
3.15 操作审计(谁在什么时候做了什么)✅ 已上线
入口:/app/audit,仅 admin 可见可用(路由 @admin_required,侧栏 admin_only)。回答的问题是「任选一个员工、任选一个时间点,他在系统里做了哪些操作」。
| 路由 | 权限 | 功能 |
|---|---|---|
/app/audit | admin | 审计查询(筛选 + 分页 + 双维度汇总) |
/app/audit/export | admin | 按当前筛选导出 CSV(BOM 头,上限 5 万行) |
/app/audit/purge | admin(POST) | 按天数清理历史记录(清理动作本身会留痕) |
采集方式:全局钩子,业务代码零侵入
app.py 的 @app.after_request 统一埋点,audit_record() 写库。三个要点:
- 写操作全记(POST / PUT / DELETE),读操作只记敏感动作 —— 白名单
AUDIT_LOG_GET_ENDPOINTS= 登出 / 三类导出 / BOM 导出 / 归档文件下载 / 导入模板下载 / 客户分享页访问 / 审计导出。普通页面浏览不记,否则日志会被翻页刷爆。审计页自身(audit)也不记。 - 登录失败单独显式记录:此时还没有会话,
get_current_user()拿不到人,只能在login()里显式调audit_record('login', ..., note='密码错误'/'账号不存在'/'账号已停用')。显式调用会置g._audit_done,after_request 不再补一条,避免同一次操作记两遍。 - 登出必须在
session.clear()之前记录,否则日志里登出的人永远是「(未登录)」。
记录内容(表 audit_logs):时间 / 账号 / 姓名 / 角色(当时快照)/ 操作分类 / 动作中文名 / 请求方法 / 路径 / 对象类型+ID+名称 / 提交内容 / IP / 浏览器 / HTTP 状态码。
- 动作名怎么来的:先查
AUDIT_ENDPOINT_LABELS(endpoint → 中文名 + 对象类型,覆盖全部 60+ 路由);列表页兼做新增/编辑的 endpoint(如clients、categories)再经AUDIT_LABEL_REFINERS按提交内容二次细化 —— 有edit_id是「编辑客户」,没有是「新增客户」,否则日志里只能看到笼统的「客户管理」。 - 操作分类推导:先按 endpoint 里的英文动词(
_delete→删除、_export→导出、_add→新增…),认不出来再用中文动作名兜底(删除/导出/上传/归档/新增/修改),仍认不出则写操作=修改、读操作=查看。分类共 9 种:登录 / 新增 / 修改 / 删除 / 导出 / 上传 / 归档 / 查看 / 其他。 - 对象解析:依次从路由变量
/scheme/<sid>、查询串?id=(表单页/product/form?id=12)、表单主键(edit_id/id)三个来源取 ID;取到后再按白名单表把 ID 翻译成名称,审计表里直接显示「方案 #2 · Rio.的智能方案」而不是一串数字。注意:删除操作的对象在日志写入时已被删掉,名称必然为空,属预期。 - 脱敏:
password/password_hash/new_password/token等 9 个键一律写**;上传文件只记文件名不记内容;单字段值截断 300 字,整段 JSON 截断 4000 字。 - 真实 IP:经 Nginx 反代后
remote_addr恒为127.0.0.1,取X-Forwarded-For首段,没有再看X-Real-IP。主 vhost 的两个 proxy 配置均已带这两个头。 - 审计写入全程吞异常,COS/数据库故障只影响日志,绝不阻断业务请求。
查询界面
- 筛选:员工(下拉,含已删账号的历史记录)、操作分类、具体动作(下拉,全部 60+ 动作中文名)、起始 / 结束时间(
datetime-local,可精确到分钟)、IP 片段、关键字(路径 / 提交内容 / 对象名称 / 动作名模糊匹配)。 - 快捷时间:今天 / 近 24 小时 / 近 7 天 / 近 30 天 / 全部(前端 JS 填表即提交)。
- 汇总卡:命中条数、涉及账号数、最早/最晚时间。
- 双维度汇总表:「按员工汇总」(点击账号即按该员工筛选)、「按操作汇总」(点击动作即按该动作筛选)。
- 明细表:时间 / 员工 / 分类 / 动作(含
方法 + 路径小字)/ 操作对象(含名称)/ IP / 状态码(≥400 标红)/ 提交参数(<details>折叠展开)。每页 50 条。 - 导出 CSV:沿用当前筛选条件,与列表页共用
_audit_filters(),保证两边口径一致;文件名审计日志_YYYYMMDD-HHMM.csv。 - 清理:输入天数(1–3650,默认 90)删除更早的记录;POST 请求在删除后才被 after_request 记录,所以清理动作本身必然留痕。
已知边界:审计从 2026-09-03 上线才开始采集,此前的历史操作无法追溯;只记写操作与敏感读操作,纯浏览不在其中。
3.14 系统设置(乙方信息与合同条款参数)✅ 已上线
入口:/app/settings,仅 admin(admin_only: True,挂在侧栏「员工」组)。
单记录表(system_settings,固定 id=1)维护乙方(我方)主体信息与合同条款参数,供导出文档实时引用:
| 字段 | 用途 | 出现在 |
|---|---|---|
provider_type | 主体类型:1=个人 / 工作室(默认),2=公司。决定全套措辞,见 §3.7.2 | 全部三类产物 + 文件名 |
company_name | 个人=姓名或工作室名;公司=公司全称 | 封面、双方信息表、签署页、报价单、方案册「设计服务方」 |
company_contact / company_phone | 联系人 / 电话 | 双方信息表、签署页、报价单页脚 |
company_address | 联系地址(个人选填,留空不出)/ 公司地址(公司必出) | 双方信息表 |
company_tax_id | 身份证号(个人选填,留空不出)/ 税号 / 信用代码(公司必出) | 双方信息表 |
warranty_years | 质保年限(默认 1) | 条款 6.2 |
trial_months | 试用期月数(默认 3) | 条款 6.1 |
service_call_fee | 上门费(元/次,默认 400) | 条款 6.3 |
cos_archive_enabled | 交付物是否自动归档到 COS(默认 1) | §3.7.3 |
cos_export_prefix | 桶内目录前缀(默认 deliverables) | §3.7.3 |
cos_archive_acl | 归档文件权限:private(默认,推荐)/ public-read | §3.7.3 |
COS 归档配置区:开关 + 目录前缀输入框 + 权限下拉(私有 / 公共读),并实时显示当前桶名与凭证状态。凭证来自 systemd Environment(QLZ_COS_SECRET_ID / QLZ_COS_SECRET_KEY / QLZ_COS_BUCKET / QLZ_COS_REGION),不写进仓库;未配置时页面标红提示。
- 页面交互:顶部「个人 / 工作室」与「公司」二选一单选钮,切换时 JS 实时改写字段标签、placeholder、区块标题与提示文案(无需提交即所见即所得)。
- 读取路径:
doc_export._load_company()每次导出实时读库;读库失败时退回占位文案(个人:「(请在【系统设置】中填写您的姓名或工作室名称)」;公司:「(请在【系统设置】中填写公司全称)」),导出不中断。 - 写入路径:
system_settings()的 POST 分支先SELECT COUNT(*),为空则INSERT (id) VALUES (1)兜底,再UPDATE,避免记录缺失时静默失败;provider_type一并持久化。 - 迁移:
provider_type TINYINT NOT NULL DEFAULT 1(兼容旧库,升级后默认个人模式)。 - 页面底部附「字段 → 出现位置」对照表,并注明个人模式下身份证号 / 联系地址留空即整行省略。
3.16 新人使用手册 /guide/ ✅ 已上线
入口:侧栏「帮助」组「使用手册」(全员,blank=True 新标签页打开);概览页「文档手册」卡(与「功能模块」分开,见 §3.11);方案列表页与控制图页顶部各有一个「❓ 使用手册」按钮(后者锚定 #s5 标点位章节)。页面本身 http://140.143.142.129/guide/,Nginx 直出静态页,不经过 Flask。
定位:面向零基础新人的使用视角文档。目标是「照着 8 步走完,能独立产出可报价、可交付的方案」。与 /docs/(技术视角)互补,不重叠。
| 文件 | 说明 |
|---|---|
docs/guide/index.html | 手册正文(手写 HTML,复用后台 CSS 变量) |
docs/guide/shots/*.png | 9 张真实后台界面截图(01-scheme-list … 09-quote) |
docs/guide/check_sync.py | 同步自检脚本,见 §0.1 |
.deploy/sync_guide.sh | 一键发布:自检 → scp → chown → 逐条验证 200 |
.deploy/docs_static.conf | Nginx location 片段(/docs/、/guide/) |
内容结构:给谁看的 → 角色权限表 → 术语表(17 条)→ 8 步操作(每步含目的 / 字段表 / 截图 / 警告)→ 按钮速查表(18 行)→ 新人踩过的坑(12 条,按频次排序)→ 常见问题(7 个 Q&A)→ 给维护者的同步指引。
Nginx 关键坑:location 必须写成 ^~ /guide/。宝塔主站 conf 里有一条 location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$,正则 location 优先级高于普通前缀 location,会把目录下的图片全抢走返回 404;且它带 error_log /dev/null,错误日志里查不到任何痕迹。用 ^~ 才能让前缀匹配压过正则。/docs/ 同理。
4. 数据模型(表清单)
| 表 | 关键字段 | 关联模块 |
|---|---|---|
users | username, password_hash, role, display_name, phone, status | 3.1 |
clients | name, phone, address, tier, budget, source, status | 3.2 |
categories | name, parent_id, sort | 3.3 |
products | category_id, name, brand, model, price, protocol, access_mode, tags, net_role, ap_cover, panel_type, panel_height, woodwork, image_url, status | 3.3 / 3.9 |
product_drafts | source_url, title, brand, model, price, status, product_id | 3.3 |
scenes | level, name, description, devices, sort_order, status | 3.4 |
schemes | code, name, level, area_m2, client_id, customer, design_rate, labor_rate, status | 3.5 / 3.7 / 3.14 |
scheme_rooms | scheme_id, name, sort_order | 3.5 |
scheme_items | scheme_id, room_id, product_id, qty, note | 3.5 |
scheme_drawings | scheme_id, name, image_url | 3.5 |
scheme_points | drawing_id, scheme_item_id, room_id, product_id, label, x_pct, y_pct, point_kind(switch/light/curtain/sensor/ap/空), light_type, watt, light_dir | 3.5 / 3.9.1 |
scheme_point_links | drawing_id, switch_point_id, ctrl_point_id, link_type(physical/virtual), key_no(1-3), action(single/double/long), mode_id(UNIQUE(switch_point_id,key_no,action,ctrl_point_id,mode_id)) | 3.9.1 / 3.9.2 |
scheme_light_modes | scheme_id, name, sort_order, created_at(UNIQUE(scheme_id, name)) | 3.9.2 |
scheme_mode_items | mode_id, point_id, state(0/1), brightness(1-100 或 NULL)(UNIQUE(mode_id, point_id)) | 3.9.2 |
scheme_shares | scheme_id(UNIQUE,一方案一条), token, expire_at, show_model | 3.5 |
scheme_files | scheme_id, kind, fmt, fname, cos_key, acl, size, created_by(UNIQUE(scheme_id,kind,fmt)) | 3.7.3 |
quote_templates | name, data(JSON) | 3.7 |
projects | code, name, client_id, stage, budget, contract_amount, paid_amount, owner, start_date, expected_date | 3.6 |
project_stages | project_id, stage_key, stage_name, done, done_date | 3.6 |
project_schemes | project_id, scheme_id | 3.6 |
audit_logs | ts, username, display_name, role, category, action, action_label, method, path, target_type, target_id, target_desc, detail, ip, ua, status_code(索引:ts / username / category / (target_type,target_id)) | 3.15 |
system_settings | id(固定1), provider_type, company_name, company_contact, company_phone, company_address, company_tax_id, warranty_years, trial_months, service_call_fee, cos_archive_enabled, cos_export_prefix, cos_archive_acl | 3.7.2 / 3.7.3 / 3.14 |
5. 变更记录
倒序记录。每次功能修改在此追加,并同步更新上方对应模块章节。
2026-09-09 · 六个方案子页面统一导航条(_scheme_nav.html)✅
- 痛点:方案设计 / 户型图 / 数字孪生 / 自动BOM / 智能报价 / 智能施工图 6 个页面互相之间没有
跳转入口。进了「户型图」切不到「数字孪生」;进了「智能报价」只有一个「返回设计」,要看别的
得先退回方案详情再重新进。
- 做法:新建宏
app/templates/_scheme_nav.html→scheme_nav(s, cur, base),渲染
设计 · 户型图 · 数字孪生 · BOM · 报价 · 施工图 6 个入口,6 个页面 {% from ... import %}
共用;各页在导航条后面追加自己的专属按钮(导出方案册/合同、导出 SVG/米家配置清单、导出 CSV、
导出Word/PDF、编辑方案、使用手册)。
- 当前页标记:用
.btn.ghost.cur(蓝描边 + 浅蓝底rgba(47,127,209,.10)+ 加粗),
不占用 .btn 实心——实心留给「导出 SVG / 导出 CSV」这类本页主操作。此前用实心表示当前页,
在户型图页会同时出现「户型图」+「导出 SVG」两个实心,分不清哪个是导航哪个是操作。
- 验证:
.deploy/verify_scheme_nav.py35 项断言全绿(6 页 × 导航齐全/当前页唯一高亮/
链接带方案 id/专属按钮保留/按钮无裁剪 + 3 条真实跳转:户型图→孪生、报价→户型图、施工图→BOM)。
孪生页 24 项断言仍全绿。
2026-09-09 · 修「顶栏按钮在窄窗口被裁到屏幕外」(全站,非孪生页独有)✅
- 现象:方案详情页 / 户型图页顶部 8 个操作按钮,窗口宽度 ≤1100px 时挤成两行(容器 79px 高),但
.topbar写死height:var(--top-h)=60px,.actions垂直居中后第一行整体溢出到 topbar 上沿之外(实测y=-10)——BOM / 报价 / 水电交底图 / 户型图 / 数字孪生 / 导出方案册 6 个按钮完全不可见,只有第二行的导出合同 / 编辑方案露出来。 - 修:
.topbarheight→min-height,padding:0 26px→10px 26px;@media(max-width:820px)里同步padding:10px 14px。宽屏内容不足 60px 时 min-height 兜底,视觉零变化;窄屏自动长到 98~128px,按钮全部可见。 - 验证:8 档宽度(1512/1440/1280/1100/1024/900/768/414)× 10 个页面回归,topbar 高度 60→128px,
y<0裁剪项 0;数字孪生页 24 项断言仍全绿。
2026-09-09 · 数字孪生入口 + 场景按房间差异化 ✅
- 入口:方案详情页加「数字孪生」按钮(
/scheme/<sid>/twin),独立页面、不动plan.html,与原户型图点位/控制图完全分离。 - 场景重构:原「会客」「观影」「起夜」按 light_type 筛,但库里 17 灯只有 4 个有 light_type,导致「会客=17 全亮」「观影=0 全黑」与场景语义相反。改成「房间 → 亮度」映射 + 类型衰减(MAIN=全量、AMBI=60%)+ 房间名错配兜底(不污染全关/全亮)。新结果:会客 11/观影 8/起夜 8/用餐 5/全亮 17/全关 0。
- 点位补 room:孪生路由 SQL JOIN
scheme_rooms取房间名,传给前端让设备列表显示房间归属。 - 验证:
.deploy/verify_twin_entry.py24 项断言全绿(含场景差异化、拖拽改视角、JS 零报错)。截图_shots/twin-entry-view.png会客态可见 11 个光晕,3D 等轴测投影成立。
2026-09-09 · 修「拖动点位与鼠标位置不符」+「外接屏点位漂移」(同根)✅
- 两 bug 同根:canvas
min-height: 360px与 img 高度解耦——.pt{top:Y%}相对含空白的 canvas算,不是相对图。拖动时用canvas.getBoundingClientRect()还含 1px border,与 .pt 定位参考系不同。 - 修 1:删
min-height→aspect-ratio: var(--ar, 4/3);img onload 写--ar = naturalWidth/Height,canvas 高度严格 = img 高度。跨屏点位视觉位置永远一致。 - 修 2:拖动 / 点击添加 改用
(document.getElementById('planImg') || canvas).getBoundingClientRect()——img 无 border,与 .pt 定位参考系完全一致。拖动跟手 < 1.1px(亚像素)。 - 真因判定:bug 1「拖动不符」其实就是 bug 2「点位漂移」——因为参考系都是含空白的 canvas rect,看起来鼠标 = 点位(都偏同一量),但相对图就不对。修复后视觉跟手 < 1.1px。
验证(Playwright):100% 拖动偏差 X=0 Y=-0.55px ✓;150% 缩放拖动偏差 X=-0.007 Y=-0.66px ✓;跨屏拖动偏差 X=0 Y=-1.09px ✓;dataset 位置 == 视觉相对图位置(差 0~0.44px)✓;30 路由 0 失败;dark 4 / light 8 持平。
2026-09-09 · 修「拖动点位与鼠标位置不符」+「外接屏点位漂移」(同根)✅
- 两 bug 同根:canvas
min-height: 360px与 img 高度解耦——.pt{top:Y%}相对含空白的 canvas算,不是相对图。拖动时用canvas.getBoundingClientRect()还含 1px border,与 .pt 定位参考系不同。 - 修 1:删
min-height→aspect-ratio: var(--ar, 4/3);img onload 写--ar = naturalWidth/Height,canvas 高度严格 = img 高度。跨屏点位视觉位置永远一致。 - 修 2:拖动 / 点击添加 改用
(document.getElementById('planImg') || canvas).getBoundingClientRect()——img 无 border,与 .pt 定位参考系完全一致。拖动跟手 < 1.1px(亚像素)。 - 真因判定:bug 1「拖动不符」其实就是 bug 2「点位漂移」——因为参考系都是含空白的 canvas rect,看起来鼠标 = 点位(都偏同一量),但相对图就不对。修复后视觉跟手 < 1.1px。
验证(Playwright):100% 拖动偏差 X=0 Y=-0.55px ✓;150% 缩放拖动偏差 X=-0.007 Y=-0.66px ✓;跨屏拖动偏差 X=0 Y=-1.09px ✓;dataset 位置 == 视觉相对图位置(差 0~0.44px)✓;30 路由 0 失败;dark 4 / light 8 持平。
2026-09-09 · 修「点 + 弹『请先在右侧选择要放置的设备』」+ pan 完不误触 ✅
- 真因:工具栏
#zoomToolbar在#canvas内部,点 + - ⟲ 按钮事件冒泡到#canvasclick handler;该 handler 只排了.pt/#glowToggle,没排按钮/工具栏——落到「点击画布空白处 → 放置已选设备」逻辑,因没选设备 alert。 - 修 1:
canvas click加closest('.zoom-toolbar, button, .pt-del, input, select, textarea, label, a')一刀切排除。 - 修 2:pan move 累计 > 4px 时设
dragged=true——这是上一轮 pan 改造的尾巴,pan 完时没设 dragged,浏览器派发的 click 还会走「加设备」。
验证(Playwright):点 + → 1.2 弹窗 [] ✓;pan 完 → 弹窗 [] ✓;真点空白 → 该弹还弹 ✓。
2026-09-09 · 画布缩放改成「只工具栏」+ 空白处拖动整体平移 ✅
- 移除 wheel/双指缩放:滚轮 + Safari gesture 只
preventDefault阻止页面滚动与 trackpad pinch-to-zoom 误触,不再调 setScale;#canvas{touch-action:none}兜底触控手势。 - 新增空白处 pan:
pointerdown排除点位/连线/气泡/工具栏/按钮/表单/光晕,命中空白进入 pan 流程,move 累计panX, panY;cursor:grab静态暗示 +grabbing拖动中提示。 transform改为translate(panX, panY) scale(s);setScale的transform-origin公式减去panX/panY(光标锚点保持);⟲ 重置同时清零 pan。- 去掉
.zoom-wrap的transition:transform .15s(pan 时延迟/拖尾问题);清掉画布 inlinecursor:crosshair(让 CSSgrab生效)。
验证(Playwright,生产方案 2):滚轮 5 次 scale 不变 ✓;工具栏 + → 1.2 ✓;空白处拖 100,100 → pan=(100,100) grabbing ✓;拖点位不抢 ✓;⟲ → 全归零 ✓;JS 0 错;30 路由 0 失败;dark 4 / light 8 持平。
2026-09-09 · 控制图控制关系「即建即见」+ 路由 L 形化 + 光晕崩溃真因修复 ✅
- 局部刷新代替整页 reload(解决「建好关系后有时没线要刷新」):
createLink/createModeLink/ 删除 link 三个回调不再location.reload(),改为把新 link 推入(已存在则替换)LINKS数组 →window.renderLinks() + renderLinkPanel()。图纸/模式/缩放等状态不丢,关系一建好线就出来。 - 路由改为「只拐一次弯」的 L 形(解决「线有交叉不好看」):原先「先错开车道 + 中间拐点」的 4 段折线在串联场景下大量内部交叉。改为经典 L 形(A → 拐点 → B,拐点贴在目标 x 或 y 上)。多条同向线自然叠成一根「主干」再分叉,既符合电气施工图惯例,也彻底消除同开关的内部交叉。
linkPath/linkMid物理线分支各砍一半。linkFan简化:{lane, fan, perp},lane仅供虚拟线弧高倍数使用。 - 角度排序(保留以服务虚拟线):同一开关引出的多组线按目标方位角排序后分配 slot。L 形本身不需要车道,但排序让虚拟线的
fan顺序有意义(弧高按序号缩放、拱向左右交替)。 - 光晕 + 连线渲染互相隔离(顺手修复潜伏的 P0 bug):之前的
renderGlows用document.querySelectorAll('.pt')取了点位,又用canvas.insertBefore(g, pt)插入——但 zoom 改造后.pt已不在#canvas的直接子节点下(在#zoomWrap里),每次window.redraw()都会抛NotFoundError: insertBefore……更糟的是抛在redraw()头部,导致renderLinks()根本不被调用——这才是「没线要刷新才有」的真因。renderGlows改用canvas.querySelectorAll('.pt')+(pt.parentNode || canvas).insertBefore(g, pt);window.redraw()两个渲染各自try/catch,互不连累。 - 尺寸自愈:
renderLinks在cw||ch===0时隔帧重试(最多 40 帧),并新增ResizeObserver监听 canvas 尺寸变化——户型图加载慢、侧栏折叠过渡中的「整批不画线」彻底消失。 - 后端
lastrowid修复:两处INSERT ... ON DUPLICATE KEY UPDATE加id=LAST_INSERT_ID(id),否则走更新分支时c.lastrowid返回 0,前端局部刷新后这条线的id=0后续删除/聚焦全部失效。
验证(生产方案 2:21 条线、4 开关 × 3 组):
- 几何:同开关交叉 0(旧 4 段折线 = 2;新 L 形 = 0);总交叉 0~3(画布宽高比影响,非本路由可控)
- 端到端(自建自清,方案 2 / sw=56 / ct=88):新建 id=54 → 重复添加(同 sw/k/a/t)返回 id=54 ✓ → 切 virtual 返回 id=54 ✓ → 删除 ok ✓ → DB COUNT=0 ✓
- 浏览器:JS 0 错 / 0 警告;30 路由 0 失败;dark 4 / light 8 持平
- 截图:.workbuddy/pres/canvas_L.png / canvas_L_zoomed.png(zoom 207% 下 L 形干净利落,无相互穿插)
2026-09-09 · 控制图 SVG 导出修复(命名规范 + 名字可读性 + 点位对齐)✅
诉求:导出的 SVG「命名没按规范、名字跟颜色重合看不清、名字之间重叠」。
- 文件名规范:
app.py路由scheme_plan_export_svg改为<方案名>_控制图_<方案编号>.svg(例Rio.的智能方案_控制图_QLZ-20260815-002.svg),并用 RFC 5987filename*=UTF-8''<百分号编码>让中文名在浏览器正确显示;旧版<方案id>_<时间戳>.svg(如2_202609091833.svg)与归档规范对不上。 - 点位对齐:
plan_export.build()的 viewBox 不再写死 1200×800 +preserveAspectRatio="xMidYMid meet"(等比留白 → 纵向错位约 90px,点位跑到隔壁房间),改为宽度固定 1200、高度按户型图真实像素比例算(_probe_size()用 PIL 探测图尺寸,H = round(1200 * ih/iw))。 - 名字不跟颜色糊:标签文字加
paint-order="stroke"+ 白色描边 halo(stroke="#ffffff" stroke-width="3.4"),压在深色户型图上也清晰;图例下方垫半透明白底板。 - 名字不互相重叠:
_layout_labels()由「固定候选列表」改为向外螺旋扫描找空位,密集点位也不丢信息、不重叠。 - 验证:
.deploy/verify_svg_export.py(4 项全过:文件名 / 标签 halo / 名字不重叠 / 比例匹配)+.deploy/verify_svg_e2e.py(app.test_client真打/app/scheme/2/plan/export.svg,端到端 ALL PASS,header 解码为Rio.的智能方案_控制图_QLZ-20260815-002.svg)。
2026-09-10 · 设备图片压缩(库存页图片加载慢)✅
现象:库存界面刷新时图片加载缓慢。
根因(量化):缩略图仅 44×64px,但上传链路 cos_client.upload_image 完全没有压缩处理
——原样 read() 上传。实测 14 张产品图合计 5.75MB,8 张 >200KB,最大 1489KB(1338×1280);
且 products.html 的 <img> 无 loading="lazy"、无 width/height,刷新时全量拉取并产生布局抖动。
方案决策:先验证 COS 数据万象 ?imageMogr2/thumbnail/ 是否可用——实测本桶未开通
(参数被忽略,Content-Length / Content-Type 均返回原图),故「URL 加参数」零成本方案不可用,
改为服务端压缩 + 存量迁移。
改动:
cos_client.py新增_maybe_compress():等比缩到max_side内 + WebP(q82),
ImageOps.exif_transpose 保 EXIF 方向;GIF 跳过;压缩后更大则用原图。
upload_image(..., max_side=None) 默认不压缩。
app.py产品图两处上传(913product_upload/ 992product_save)传max_side=800;
户型图(2639 plan_upload)保持不压缩并加注释锁死。
products.html/drafts.html缩略图加loading="lazy" decoding="async" width="64" height="44"。
存量迁移:.deploy/migrate_compress_products.py,14/14 成功,
5.75MB → 0.35MB(−94%);原图对象保留不删,可回退。
验证:.deploy/verify_img_compress.py 端到端(类照片 1600×1600 PNG 714KB → 800px WebP 7KB,−99%;
户型图上传源码断言未开压缩);库存页 200 且 14 张全 lazy + webp;户型图回归仍是 1541×503 PNG 原图;
服务 active、0 错误。
踩坑:直接用 python3 跑脚本拿不到 COS 凭证(QLZ_COS_SECRET_ID/KEY 只在 systemd
Environment= 里),首次迁移 14 张全部上传失败但未污染数据库(失败在写库前)。
跑一次性脚本前必须 export 那几个环境变量(systemctl cat quyuzhineng-app 可查)。
2026-09-05 · 方案详情页「就地绑定客户」(消除绑客户绕一圈)✅
app.py新增POST /scheme/<sid>/bind-client(@staff_required):
只 UPDATE client_id + customer,不动其它字段。
解绑只清 client_id,保留手填的客户名称文本。
已登记 AUDIT_ENDPOINT_LABELS 审计标签。
scheme_detail.html顶部「客户」字段从纯文本改成就地可绑下拉(onchange自动提交,
<noscript> 显示按钮兜底),无需再跳编辑页。
- 分享设置处的提示文案改为「在上方「客户」处绑定后会显示成…」,把用户指向刚做的下拉。
验证(./deploy/verify_bind_client.py`,17 项,服务端 + 浏览器端到端):
详情页下拉有 11 个选项含 Rio.;POST 写 client_id=4 + customer=Rio.;
分享页标题立即变「Rio.的L2方案」;解绑还原 + 完全回滚。后台 30 路由 0 失败;
计价体检 0 失败;跨页 92/92。
2026-09-05 · 方案分享页完整化(标题优化 + 报价完整 + 存量数据修复)✅
分享页呈现(§3.5)
app.py新增_share_title(s, client):标题优先「{客户名}的{等级}方案」,
未绑客户退回「{方案名}(等级)」。分享路由与后台详情页预览共用这一函数。
share_scheme.html大改:
- 摘要条补 4 个 tag:房间数 / 设备数 / 等级 / 编号;
- <title> 与 OG meta(og:title / og:description)同步,让链接发到微信 / 社媒一眼能识别;
- 费用汇总补 大写金额(复用 doc_export._cny_capital)+ 报价日期 + 有效期 + 备注
+ 乙方署名(复用 doc_export._load_company(),个人 / 公司措辞自动切换);
- show_price=0 的替代说明去 class="muted"(CSS 里未定义,会失效),改用 inline style。
scheme_detail.html分享表单下方加标题预览 + 未绑客户提示,
引导去「方案设计 → 编辑方案」绑定客户。
存量数据修复
- 用户反馈「生成的方案还是显示『具体报价由设计师沟通后给出』」。
排查发现是上一轮「默认不勾选」版期间创建的分享记录 show_price=0 没刷回 1,
表单回显读旧值导致一直不勾。改代码必须改存量数据,否则用户感知不到。
- 一次性脚本
/tmp/fix_share_price.py刷回 1,影响 1 条记录。
新脚本:.deploy/verify_share.py(绑定客户场景断言,18 项)、.deploy/_shots_share.py
(三尺寸截图 + 横向溢出断言)。
验证:未绑客户 → 「Rio.的智能方案(L2)」;临时绑客户(id=4 Rio.)→ 「Rio.的L2方案」,
绑完自动还原(用 SQL 不走 POST,避免整行覆盖未提交字段)。
三尺寸(375 / 768 / 1280)横向溢出 0;后台 30 路由 0 失败;计价体检 0 失败;
未勾选路径(¥ x0 + 替代说明 + 乙方署名仍在)全部正常。
2026-09-05 · 计价口径收敛为单一入口 + 分享页价格开关修正 ✅
计价口径统一(§3.7.0 新增)
app/db.py新增BILLABLE_ROOM_JOIN(取数口径常量)与scheme_totals(sid)(唯一计价入口,
返回 设备小计, 设计费, 施工费, 合计)。
app/app.py的_scheme_totals改为转发到db.scheme_totals;方案分享页与后台报价页的合计
改走该入口,两处取设备明细的 SQL 也统一带上 BILLABLE_ROOM_JOIN。
app/doc_export.py的build()同样改走db.scheme_totals,明细 SQL 加同一过滤。- 起因:三条报价路径(分享页 / 后台报价页 / 导出)曾各写一条 SQL,只有后台报价页带
kind<>'meta' 过滤。一旦有设备挂到 meta 房间,客户拿到的报价单会比后台多钱。
线上当时 1 个方案 8 条设备全在实体房间,差异未暴露。
pymysql.connect(db=...)改为database=...(旧参数已发 DeprecationWarning,将来会移除)。
验证:三处渲染出的合计逐分一致(均为 ¥4747.13);后台 30 路由 0 失败;
6 种导出组合(报价单 / 合同 / 方案册 × docx / pdf)全部正常。
方案分享页价格开关(§3.5)
scheme_shares加show_price TINYINT NOT NULL DEFAULT 1(幂等迁移):
控制分享页是否展示设备单价、小计与费用汇总。
- 默认显示价格。分享页属交付阶段,读者是已出方案的意向客户,总价是他拍板必需的信息。
《前端设计文档.md》§1.1 的「零价格焦虑」原则只管潜客选型阶段(首页 / 选配向导),不套用到这里。
- 不勾选的用途是销售节奏:先让客户认可设计价值、暂不谈钱。此时费用汇总卡替换为
「清单只列出设备与数量。具体报价由设计师与你沟通后单独给出。」(不留空)
2026-09-04 · 后台 /app/ 侧栏 + 概览改造 + 子模板一致性收口 ✅
侧栏与概览
app/templates/base.html完全重写(161 → 415 行)。浅色侧栏(同源 26px.mark「全」+ 17 SVG 图标 + 5 序号 chip),与公共站点(首页/选配向导/功能文档/新人手册)共用同一套令牌(--accent:#2f7fd1/--accent-strong:#1f66ad/ 圆角 scale 锁),加prefers-color-scheme: dark+prefers-reduced-motion+@media print回浅色 + 全局:focus-visible。app/templates/dashboard.html改版:4 大指标.stat.primary(34px 数字 + 渐变顶)+ 3 紧凑指标.stat.mini;隐去「合同总额」(¥0 误导);方案设计.mod.core高亮;文档手册单独分块;顶部「+ 新建方案」主 CTA。app/app.py新增 Jinja2 globals:NAV_ICONS(17 个 Phosphor regular SVG)+GROUP_NUMS(01-05),侧栏与模块卡共用,不再各自内联。- 全路由回归:注入 admin 会话遍历 30 个 GET 路由(无参 + 带参 + ?scheme=),0 失败 0 跳转。
子模板一致性收口
- 13 处 Unicode 排版符号 → SVG:
pick.html的✎ ☰ ¥ ▤→NAV_ICONS.get(key);schemes.html/plan.html的「❓ 使用手册」→ SVG。修复_scheme_picker漏传key的根因(之前三个 picker 页图标全部落到 else 分支同一个)。 - 5 处旧红
#e2574c(3.68:1)→var(--danger)(5.25:1):audit 状态码染色、settings「未配置」、plan 控制图连线(.lk.physical/.lk-end/.kp-del)、模式行状态。 - 3 处旧绿
#2e9e5b→var(--success):plan.st-on、plan JS 文案、scheme_detail 复制提示。 - 50+ 处破折号 em-dash → 新 filter
| nil渲染<span class="nil">·</span>:表格空值(audit/products/profile/bom/share_scheme/scheme)、<option>占位(categories/plan × 2)、文案破折号(scheme_detail 3 类造价说明、settings 协议信息表 2 处)、JS 注释与 JS 字符串字面量。 - 18 个子模板 27 个 table 全部包
.table-wrap:base 加:where(.card,.room-card) .table-wrap{border:0;border-radius:0;background:transparent}去重描边。drawing.html4 个内联卡片 div 换class="room-card"。 app.py注册app.template_filter('nil')+from markupsafe import Markup,表格空值改{{ x | nil }}集中。- JS 字符串
schemes.html的custTxt!=='—'同步为!custTxt.includes('·')。
移动端 480 优化
- 关键 bug:原
.stats{1fr}把 4 主指标在 390 变 1x4 单列堆叠,违背 mobile 设计原则。改为1fr 1fr保持 2x2。.modgrid同理。 - 顶栏 480 隐藏搜索框(让出空间给主 CTA),各模块本地筛选兜底。
.user .me/.role隐藏。h1 缩到 15.5px、title-block 140px。 - 卡片间距 22→14,padding 收紧,table 字号 14→13。
新工具链
python3 .deploy/_shots_admin.py:截 14 张图(桌面 9 + 移动 3 + 深色 2),支持only=xxx单张重截,断言scrollWidth - innerWidth横向溢出。- 服务器
/tmp/regress_app.py:遍历 30 个 GET 路由注入 admin 会话,dump 到/tmp/dump。 - 验证:横向溢出 14 张全部 0px;渲染后 39 个 JS 脚本块 0 语法错误;残留扫描(旧色/符号/破折号)0 残留。
2026-09-04 · 功能文档 /docs/ 与新人手册 /guide/ 跨页令牌对齐 + 深色模式 ✅
公共首页 / 与选配向导 /quiz.html 已统一到同一套设计令牌,但 /docs/ 与 /guide/ 长期停留在各自旧配色——/docs/ 用的还是 #2f6df6(另一个蓝),且四个页面没有一个全局自定义焦点环(都靠浏览器默认 1px / auto / rgb(0,95,204),在浅色卡片上几乎看不见)。本轮把四个页面纳入同一套令牌,并加机器可校验的跨页一致性验证。
一、/docs/(gen_docs.py)改动
:root令牌完全对齐公共站点(--accent:#2f7fd1/--accent-strong:#1f66ad等),旧--brand/--ok令牌删除。- 新增
prefers-color-scheme: dark覆盖与prefers-reduced-motion降级(过渡压到.001ms)。 - 新增
@media print强制回浅色(否则开深色模式的浏览器打印会输出一整页深底)。 - 链接色从
--accent(4.14:1)换成--accent-strong(5.89:1);.tag同理(3.63:1 → 5.17:1)。 - 顶栏
rgba(255,255,255,.9)收编为--top-bg(深色下自动变rgba(14,17,22,.92))。 - 圆角从散落的 14px / 8px / 6px 收编为
--r-card/--r-ctl/--r-sm/--r-pill圆角 scale 锁。 code加overflow-wrap:anywhere(文档里有/www/server/panel/...这类长路径,默认不断行会把 375 宽页面撑出 251px 横向滚动)。<860px移动端:table{display:block;overflow-x:auto;max-width:100%;-webkit-overflow-scrolling:touch}+.cardpadding 减小到18px 16px。配合overflow-wrap:anywhere,文档页 375 宽下从溢出 394px → 0。
二、/guide/(docs/guide/index.html)改动
- 同样对齐公共站点令牌(保留
--ok/--err/--radius作旧名兼容别名)。 - 补深色模式 + reduced-motion + print 强制回浅色。
- 硬编码
#fff/#f8fafc/#f4f6f9/#0f1b2d全部收编为令牌;code-bg/pre-bg/top-bg各主题独立,深色下不再翻车。 - 链接色、目录 hover、强调标题从
--accent换成--accent-strong(3.89:1 → 5.55:1)。 - 顶栏装饰小方块(10x10
.dot)换成与首页同款的 26px 品牌标记「全」。 - 「页面名 — 内容摘要」10 处中文破折号统一改成中文间隔号「·」,与「全屋智能 · 平台」一致。
- 必填星号
#e2574c(对白底 3.68:1)换成--danger(5.25:1)。 sh .deploy/sync_guide.sh发布(自检→scp→chown→验证 200)。禁止手动 scp 绕过。
三、/index.html(首页)补全局焦点环
原来只给 .pk 写了焦点环,链接和按钮是浏览器默认环。与另外三页不一致。补全局 :focus-visible{outline:2px solid var(--accent);outline-offset:2px} + @supports 降级到 :focus,.pk:focus-visible 单独 outline-offset:3px 适配更大圆角。
四、新增 .deploy/_verify_docs.py(跨页一致性机器校验)
把"四个页面必须共用同一套令牌"变成 90 项断言,分 9 个 section:
| Section | 检查项 |
|---|---|
| A | 四个 URL 资源 200 |
| B | 浅色令牌跨页一致(9 个令牌 × 4 页 = 36 项) |
| C | 深色令牌跨页一致 + 文字确实比背景亮(5 令牌 × 4 + 4 = 24 项) |
| D | reduced-motion 把过渡压到 <= .01s(4 项) |
| E | 正文对比度 ≥ 4.5:1 + 链接对比度 ≥ 4.5:1(自动排除 nav/header/footer) |
| F | 真键盘 Tab 出现自定义焦点环(实线 ≥ 2px + 品牌色,不是浏览器默认) |
| G | 375 窄屏无横向滚动(4 项) |
| H | 手册 9 张配图全部 200(urljoin 相对页面 URL,不是站点根) |
| I | 公共站点零 em/en dash(2 项) |
结果:90/90 全过(生产 zhijia.zdatahome.cn)。
性能:docs 浅色 TTFB 68ms / FCP 172ms / Load 163ms / CLS 0;guide 浅色 60ms / 128ms / Load 1758ms(含 9 张配图 1906KB)。
首轮自查修掉的真实问题(验证脚本先发现再去改代码):
- 手册页
--accent链接在--bg上只有 3.89:1,未过 AA → 换--accent-strong(5.55:1)。 /docs/在 375 宽下溢出 394px。诊断:① 表格没做横向滚动 → 加display:block;overflow-x:auto;② 内联<code>里的长路径无法断行(最宽 576px)→ 加overflow-wrap:anywhere。两步后溢出降到 0。- 首页 Tab 焦点环是
1px / auto / rgb(0,95,204)—— 浏览器默认环。验证脚本之前只看"有 outline",放过了 UA 默认。收紧断言:必须是solid且 ≥ 2px 且色值在--accent6 容差内。
踩坑记录:同一条消息里对同一文件发两次 Edit 会丢更新(典型的 read-modify-write 竞态,后写的覆盖先写的)。需要多次编辑同一文件时,必须串行或合并成一次 Python 脚本(原子)。
2026-09-04 · 选配向导 /quiz.html 深度审查 + 无障碍与交互状态补齐 ✅
按 design-taste-frontend 规范对 /quiz.html 做完整深度审查后的第二轮修复。仍只动 public/quiz.html(74,269 字节)与验证脚本,业务逻辑零改动。
一、两个真实无障碍缺陷(不是风格问题,是功能缺失)
- 选项键盘不可达:选项原本是纯
div + onclick,tabindex为 0 但没有任何键盘事件处理,且单选步没有「下一步」按钮(选完自动跳页)。结果是键盘用户连第一步都走不出去。修复:补role="radio" / "checkbox"+aria-checked,容器补role="radiogroup" / "group"+aria-labelledby;选项支持Enter / 空格选中、↑ / ↓在选项间循环;切换步骤后focusStep()自动把焦点送到第一个可操作元素(preventScroll防跳动)。 - 表单边界对比度 1.24:1:输入框 / 文本域 / 下拉框用
--line(#e4e7ec),对白底只有 1.24:1。WCAG 1.4.11 要求「非文本 UI 组件的边界」对相邻色 ≥ 3:1。新增专用令牌--line-strong: #9aa3b0(3.06:1)给所有表单控件用,--line只保留给装饰性分隔线。
二、交互状态补齐(4.5 Interactive UI States)
- 补
:active:.btn按下下沉 1px、选项 / 开关 / 灯组卡 / 进度点按下scale(.985)。原来只有 hover 和 focus,按下去没有任何反馈。 - 补
:focus-visible:2px solid var(--accent)+2px offset,配@supports not selector(:focus-visible)降级到:focus。注意::focus-visible不能靠element.focus()测 —— 程序化聚焦浏览器不匹配该伪类,量到的是 UA 默认焦点环,会假阳性通过。验证脚本改用真keyboard.press('Tab')。 - 修
.opt的transition:.2s全属性通配:连outline-color都被动画化,键盘 Tab 过去焦点环要 200ms 才「渐显」出来。改为显式列举border-color / background / box-shadow / transform。
三、形状与装饰规范
- 圆角 scale 锁(4.4 Shape Consistency Lock):
--r-card 16px/--r-ctl 11px/--r-sm 8px/--r-pill 999px,全页不再出现散落的12px / 10px / 6px。保留--radius: var(--r-card)作旧名兼容。 - 删除序号旁的装饰圆点(规范明令禁止的 filler ornament)。
- 灯泡从
💡emoji 换成内联 SVG。emoji 只能靠filter假装开关、没法真正变色;SVG 用currentColor,色温滑块一动灯泡颜色跟着走,尺寸用百分比自动跟随 64 / 56 / 48px 三档。
四、一处真 bug:灯泡渲染成绿色
色温映射原为 hue = 35 + (temp/100)*(210-35)。默认 temp=50 落在 hue=122,正好是绿色 —— 住宅客厅出现绿光,属于明显错误。改为 hue = 30 + (temp/100)*35(30-65,暖橙到暖黄),跳过绿色区。两处映射(单灯演示 / 场景组合)都改。
五、顶部导航与首页同款
删除原 ← 返回体验首页 单按钮,换成与 index.html 完全同源的 .nav:brand「全 + 全屋智能」+ 三个入口(分级体验 / 智能选配 / 管理后台),当前页高亮,小于 560px 隐藏「管理后台」。跨页设计语言统一。
六、首屏补真实图片
intro 步(用户点「开始智能选配」后看到的第一屏)原本是纯文字。补 /img/scene-l2.webp,aspect-ratio:16/9 占位防 CLS,fetchpriority="high"。
验证:.deploy/_verify_quiz.py 从 44 项扩到 58 项,Sections 为 A 结构 / B 浅色令牌 / C 最短路径 ≥11 步 + 最长路径 =16 步 / G 必填校验 / D 深色令牌 + 语义色 / E reduced-motion / F 375+768 窄屏 / I 可访问性 12 项 / H 文案自检。本地 127.0.0.1:8899 与生产 zhijia.zdatahome.cn 各跑一遍,58/58 全过。性能:TTFB 5ms / FCP 40ms / Load 30ms / CLS 0 / 图片 42KB / 1 个请求。
首轮自查修掉的真实问题:
getPropertyValue('--x')返回十六进制原文(不是rgb()),原parse_rgb只认rgb(1,2,3)→ 对比度函数抛TypeError。补#hex/#abc解析分支。- 焦点环量到
rgb(36,85,136)而非 accentrgb(47,127,209):既是测试取值过早,也是上面那条transition通配的真 bug。代码改显式列举 + 测试等 320ms 后取值。 - Python heredoc 里写中文全角括号被当代码解析报
SyntaxError,改写脚本时避开。
2026-09-04 · 选配向导 /quiz.html 设计与首页对齐 + 体验断点修复 ✅
紧跟首页改版(同一轮 UX 优化)。本轮只动 public/quiz.html(59KB)与两个验证脚本,业务逻辑零改动。
- 设计令牌与首页同源:
:root换成与public/index.html完全一致的中性色 / 单一 accent / 阴影等;新增--accent-strong/--on-accent/--success*/--danger*/--warn*/--neutral等语义令牌。 - 0 处硬编码颜色(除灯具演示的物理色外):原 9 处直接写
#e7f5ee/#2e9e5b/#b94545/rgba(58,110,235,.12)/#e05656/#fff7e0/#5cb87a/rgba(47,158,110,.18)等全部收编为令牌。 - 补深色模式(
prefers-color-scheme,深色下 accent 改浅蓝#6cb0f0,按钮文字改深色#0e1116)和prefers-reduced-motion(所有过渡压到 1e-06s)。 - 主按钮 AA 对比度:5.90:1(浅)/ 8.20:1(深)—— 旧版绿色白字只有 3.35:1。
- 零 em/en dash(17 处)、6 处中文半角直引号改直角引号。
- 生态口径复检:未发现华为 / HomeKit / Aqara 残留。
- 真实体验断点修复:
- 照明搭配步的「下一步」在被禁用时加 .next-hint 提示(「先在上面的搭配里勾选…」),勾选后自动隐藏。
- 联系表单必填未填时标红,输入即清除(之前要等下次点保存才消失)。
- 新增:
theme-color跟随主题、内联 SVG favicon(消除 404)、descriptionmeta。
验证:.deploy/_verify_quiz.py(Playwright 44 项全过)覆盖资源 / 令牌 / 最短路径 11 步 + 最长路径 16 步 / 联系表单必填 / 深色 / reduced-motion / 375 + 768 窄屏 / 静态文案自检。.deploy/_shots_quiz.py 输出 6 张截图(浅色首页 / 照明步 / 多选步 / 深色首页 / 深色照明步 / 移动端)。性能:TTFB 13ms / FCP 92ms / Load 52ms / CLS 0 / 0 个外部图片请求(前端全文字 + 内联 SVG)。
首轮自查修掉的真实问题(验证脚本先发现再去改):
- 第一步是
intro类型、没.opt,原断言「第一步有可选项」误报 → 改判「有下一步按钮」。 - 进度点 16 个但向导是条件分支(5 个「需要吗」决定要不要进条件步),原断言「必须走 16 步」永远失败 → 拆成「最短路径 ≥ 11」和「最长路径 = 16」两条。
- 走查循环没拦「同一步换页前点下一步」导致去点禁用按钮 → 加
still=(同 step-q 文本)和not nb.is_disabled()两道闸。
2026-09-04 · 公共首页 / 改版:从「营销页」重做为「分级选型工具」 ✅
一、流程重构(本次重点)
- 主 CTA 从页面顶部移到底部。原版一进页面就是「开始配置我的全屋智能」,用户还没看懂 L1-L4 就被推去做 3 分钟问卷;看完后又要滚回顶部才能行动。现在 Hero 只有一个「看看四级的差别」锚点按钮,看完分级后底部才是「开始智能选配」。
- 默认等级 L1 改为 L2。L1 是租房尝鲜档,真实客群是自有住房;且与后台方案默认等级(L2 联动)保持一致。
- 新增「四级横向对比」折叠表(8 个维度:操作方式 / 触发方式 / 网络要求 / 传感器 / 暖通联动 / 断网可用 / 预算量级 / 决定时机)。原来要看差异得来回点,记忆负担重。
- 新增预算量级锚点(千元级 / 万元级 / 数万元 / 十万级)。这是选等级时最大的决策障碍,原来完全没有。
- 「落地提醒」从页面最底部提到 CTA 之前,拆成三块:网络地基、品牌决定功能上限、面板融合必须赶在水电阶段。原来是一大段文字埋在最底,而它直接影响可行性决策。
- 新增 URL hash / 键盘导航:
#L3可直达并分享,左右方向键切换等级。
二、视觉重做(遵循 design-taste-frontend 规范)
- 单一 accent 锁:删除按等级换色(原 L1 绿 / L2 蓝 / L3 紫 / L4 橙),全页统一品牌蓝
#2f7fd1+#1f66ad(按钮底,白字对比 5.88:1 过 AA)。L3 的#6b54d6是典型 AI-purple。 - 删除 CTA 的绿→蓝→紫三色渐变(AI slop 标志),改为纯色 + 阴影。
- 补真实空间摄影:生成 5 张图(Hero + 4 个等级场景),WebP 压缩后共 344KB。原版零图片,纯文字页。
- 新增深色模式(CSS 变量 +
prefers-color-scheme,全页统一不反转)、prefers-reduced-motion、内联 SVG favicon(消除 404)。 - Hero 改为非对称 split(文 \| 图),补齐 meta description / OG 标签(原版没有)。
三、内容修正
- 生态口径:L4 原写「米家 / 华为 / HomeKit / Aqara 统一管理」,违反项目「只用米家」约定,改为「全宅统一调度」;
caveat保留「优先选择米家开放生态」。 - 零 em-dash:全文清除
——(原版大量使用)。 - 修正半角引号、半角括号、面积边界重复(原「120㎡ 以上 / 120㎡ 以内」→「120㎡ 以内 / 超过 120㎡」)。
- 删除未经验证的「3 分钟」耗时数字。
四、性能
CLS 从 0.1536 降到 0(等级卡片静态写入 HTML,不靠 JS 填充;其余区块在首屏视口外)。实测:TTFB 63ms / FCP 180ms / Load 268ms / 图片 81KB / 请求 4 个。
五、验证
.deploy/_verify_home.py(Playwright,21 项全过)+ .deploy/_shots_home.py(截图 + 性能指标)。可传 URL 参数对任意环境跑:python3 .deploy/_verify_home.py http://140.143.142.129/。
2026-09-03 · 新人使用手册 /guide/ 上线 + 文档同步机制 ✅
一、新人使用手册(§3.16) —— 面向零基础新人的使用视角文档,8 步走完能独立产出可报价方案。
- 内容:给谁看的 / 角色权限表 / 术语表(17 条)/ 8 步操作(每步含目的、字段表、真实截图、警告)/ 按钮速查表(18 行)/ 新人踩过的坑(12 条按频次排序)/ 常见问题(7 个 Q&A)/ 给维护者的同步指引。
- 截图:9 张真实后台界面实拍(
shots/01-scheme-list.png…09-quote.png),按钮文案全部照抄真实界面。 - 入口:侧栏「帮助」组新增「使用手册」(全员、
blank=True新标签页)+「功能文档」(admin);方案列表页顶部「❓ 使用手册」;控制图页顶部「❓ 使用手册」锚定#s5。 - NAV 改造:新增
desc(模块一句话说明)与blank两个字段;概览页「功能模块」网格改为{% for n in nav if not n.group %}跳过分组标题(原版会把分组标题渲染成空卡片),说明文字取n.desc(原版是一长串elif n.key==...硬编码,已删)。 - 概览页文档手册分块(同日追加):
NAV再加doc标记,doc=True的两项不再混进「功能模块」网格,改用{% set docs = nav | selectattr('doc') | list %}单独渲染成「文档手册」卡,.docgrid/.doc紧凑样式(浅蓝底 + 左侧色条)与业务模块视觉区分,入场动画接在最后一个 mod 之后。admin:14 模块 + 2 文档;designer:7 模块 + 1 文档。
二、文档同步机制(§0.1) —— 把「功能更新要同步文档」从口头约定变成机器可校验的门禁。
docs/guide/check_sync.py:扫 7 个模板的真实按钮文案与手册交叉比对,报[缺失](阻断)/[失效]/[可能过期]/[断图](阻断)。.deploy/sync_guide.sh:先自检再上传(有缺失/断图直接中止,退出码 1),然后 chown、逐条 curl 验证 200。禁止手动 scp 绕过。- 提取器两个坑:
{% if %}X{% else %}Y{% endif %}的两种状态文案都要取(如「生成分享链接」→「保存分享设置」);纯符号(●×)不算按钮文案。
三、首轮自检发现并修掉的手册错误(证明机制有效)
- 「加房间」按钮实为
+ 房间(手册误写+ 添加房间);加设备不是「房间行点添加设备」,而是卡片顶部一整排表单,且必须先选房间再选类型,设备下拉才有内容(新人第一坑)。 - 补进手册:
水电交底图四个页签(水电预留 / 网络布线 / 开关面板 / 木作灯光)、BOM/报价/水电三页共有的返回设计、删除房间、删除子系统、保存分享设置。
四、Nginx 关键坑(已修)
/guide/ 页面 200 但 shots/*.png 全 404。根因:宝塔主站 conf 第 52 行 location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ 正则 location 优先级高于普通前缀 location,把图片请求抢走;且它带 error_log /dev/null,错误日志里查不到痕迹。修法:location 前加 ^~(^~ /guide/、^~ /docs/),让前缀匹配压过正则。
2026-09-03 · 控制图 P2 上线:灯光模式(场景联动)+ 按键动作表 + 未受控警告 ✅
一、灯光模式(§3.9.2) —— 把 P1 的"一开关一灯"扩展为"一键触发一组灯的目标态",并把开关的键位动作做成一张正式表。
- 数据:
scheme_light_modes(id, scheme_id, name, sort_order, created_at,UNIQUE(scheme_id, name))+scheme_mode_items(id, mode_id, point_id, state, brightness,UNIQUE(mode_id, point_id))。「模式触发 link」复用scheme_point_links:ctrl_point_id=0且mode_id>0,强制为 virtual 线。 - 接口:
POST /scheme/<sid>/mode/save(upsert,全量替换 items)、POST /scheme/<sid>/mode/<mid>/delete(级联清link.mode_id保留行)、GET /scheme/<sid>/mode/data(前端预览用);POST /plan/link扩展mode_id>0分支(开关+模式双校验)。 - 前端 plan.html:右侧「灯光模式(场景联动)」卡 + 模式编辑模态(按房间分组,checkbox + 开/关 select + 亮度 number)+ 模式预览(
.preview-on/.preview-off实时金底光晕,DOM 渲染不落库)+ 「点位自检」卡(未受控灯 + 空开关警告,导出前自查)。 - 按键动作表(
#linkList):列表升级为键位 | 动作 | 目标 | 类型 | 删除表头(按 key_no 升序 + 动作顺序排列),模式目标用 ⚡金底标记;空键中间缺口时提示「⚠ 键 X 还没配动作,可以绑定到已有模式(上方下拉),空着交付时客户会问这个键干嘛的。」(仅中间缺口告警,单键开关天天被念不提示)。 - 连线渲染扩展:控制模式常驻
.lk-bub-key键位徽标(黑底"键N·动作" / 模式自环金底"⚡键N·动作");模式 link 在图上画成自环(开关点甩出 30px 再绕回),区别于普通开关→灯的折线/曲线。 - 审计:
plan_mode_save / plan_mode_delete / plan_link_add / plan_link_delete全部进AUDIT_ENDPOINT_LABELS;AUDIT_ID_KEYS补(mid, 灯光模式)/(mode_id, 灯光模式)/(lid, 控制关系)/(link_id, 控制关系);AUDIT_TARGET_TABLE补'灯光模式': ('scheme_light_modes', 'name')。 - 冒烟
.deploy/smoke_p2.sh(12 步全过):建临时开关灯 → 新建模式 → 编辑验证全量替换 → 创建模式触发 link(验证 mode_id / link_type=virtual / ctrl=0)→ 重复提交 upsert 不增行 → 非法 mode_id 拒绝 → 起点非开关拒绝 → mode_id=0 + ctrl=0 拒绝 → GET 模式数据 → 删模式级联清 link.mode_id(行保留)→ 清理临时 → 审计日志全部留痕。
二、几点设计决定
- 模式按方案而非按户型图,方便一套灯方案跨图纸复用。
- 删除模式保留 link 行但把
mode_id清零:比 ON DELETE 容易解释("这关系还有,只是触发器没了"),也避免删除模式时无意中把物理线弄丢。 - 模式 link 强制为 virtual:场景联动是无线事件,不存在物理线缆,无需给客户解释为什么一条虚线是场景。
state=0是合法值:_load_modes读取时专门1 if int(it['state'] or 0) == 1 else 0—— 旧版it['state'] or 1把 0 错读成 1,调试后修。- 模式预览的
.preview-on用金色实心+光晕,与浏览模式默认蓝色圆点视觉对比强,且不落库避免误操作。
2026-09-03 · 控制图 P1 上线(开关-灯-窗帘-情景控制关系可视化)+ 设置页分段控件修复 ✅
一、控制图(§3.9.1) —— 给方案详情页的户型图点位层加上「控制关系」可视化与编辑。
- 数据:
scheme_point_links新表(drawing_id × switch × ctrl × key_no × action × mode_id,UNIQUE 防重复),scheme_points.point_kind加列(switch / light / curtain / sensor / ap / 空串)。 - 点位类型派生(app.py
_derive_point_kind,顺序敏感,仅回填空值不覆盖人工设置):面板类型 {零火,单火,KNX,情景} → switch;品类含「灯/光源」→ light;含「窗帘/开合帘/梦幻帘/香格里拉」→ curtain;含「传感器/探测/人体/存在/门磁/水浸/烟感/温湿度」→ sensor;含「无线AP/面板AP/吸顶AP/AP面板」→ ap。 - 接口:
POST /scheme/<sid>/plan/link(upsert)、POST /scheme/<sid>/plan/link/delete/<lid>;校验:两端同图同方案 / 起点必须 switch / 终点不能是 switch / 起点存在。 - 点位级联:
plan_point_delete同步删scheme_point_links WHERE switch_point_id=? OR ctrl_point_id=?。 - 审计:
AUDIT_ENDPOINT_LABELS新增plan_link_add「新增控制关系」/plan_link_delete「删除控制关系」;AUDIT_ID_KEYS加(lid, 控制关系)/(link_id, 控制关系)。 - 前端 plan.html:两层叠加(z-index 3/4)——
#linkLayerSVG(pointer-events:none,渲染所有 link)+#bubbleLayer(聚焦气泡);.pt-dot.k-*五类分色(switch 蓝方 / light 金★ / curtain 紫 / sensor 青 / ap 灰);.pt.sel/.pt.dim受控态;.lk.physical红实线 2.2px(曼哈顿正交三折)、.lk.virtual黄虚线 dasharray 7 5(二次贝塞尔拱起);点击高亮(聚焦态加粗 + 阴影 + 端点圆 + 气泡);拖拽点位时连线跟随(window.redraw());拖拽与点击冲突——pointerdown 重置dragged=false,位移 >0.4% 才算拖动。 - 控制关系面板:浏览/控制模式切换;键号(1-3) / 动作(单击/双击/长按) / 线型(物理/虚拟) 三下拉;#linkList 显示已建关系;图例「━ 物理(布线)/ ╴╴ 虚拟(无线/场景)」。
- 冒烟
.deploy/smoke_p1.sh(13 步全过;v2 用ZZTEST-临时点自建自清——v1 用真实点位 21 把已删过一次,复盘后改)。
二、设置页分段控件修复(§3.14)
- 问题:用户截图反馈「乙方主体类型」分段控件在个人模式下「个人 / 工作室」文字不可见(疑似蓝底白字但底色未生效 +
:checked + span优先级竞争)。 - 修复(settings.html head
<style>):.ptab label.on加!important(背景+颜色),.ptab label.on span{color:#fff}兜底 span,加 font-weight:600 + 底部 inset 阴影,加 hover 反馈。 - 验证(实拍):个人/公司两态切换均清晰可见,蓝底白字加粗;用户 settings.provider_type 未污染(仍为 1)。
2026-09-03 · CAD 图纸解析 P0 前置落地(ezdxf + ODA + Xvfb)✅
详见 §3.9.1 之前的设计方案 点位控制图设计方案.md §6.2/§6.7/§6.8/§6.10/§9。核心结论:依赖链路 + 房间识别算法全部跑通,可直接进 C1(上传 + 图层清洗 + SVG 底图)开发。
2026-09-03 · 新增操作审计(仅管理员)+ 10 个破坏性路由改 POST✅
用户诉求:*「添加审计功能 给 admin 账号 只有 admin 账号能查看审计界面,审计可选择任意员工任意时间点在前端进行了哪些操作」*。
一、操作审计(新模块 §3.15,仅 admin)
- 采集:
app.py加@app.after_request全局钩子 +audit_record(),业务代码零侵入。写操作全记,读操作只记白名单敏感动作(登出 / 各类导出 / 归档下载 / 客户分享页 / 审计导出),审计页自身不记。 - 新表
audit_logs:时间 / 账号 / 姓名 / 角色快照 / 分类 / 动作中文名 / 方法 / 路径 / 对象类型+ID+名称 / 提交内容 / IP / UA / 状态码,索引ts、username、category、(target_type,target_id)。 - 动作名:
AUDIT_ENDPOINT_LABELS覆盖全部 60+ 路由;列表页兼做增删的 endpoint 再经AUDIT_LABEL_REFINERS按提交内容细化(clients有edit_id→「编辑客户」,没有 →「新增客户」)。 - 操作分类:英文动词优先(
_delete/_export/_add…),中文动作名兜底,写操作默认「修改」、读操作「查看」。 - 对象解析:路由变量 → 查询串
?id=→ 表单主键三级取值,再按白名单表翻译成名称(审计表直接显示「方案 #2 · Rio.的智能方案」)。 - 脱敏:密码类 9 个键写
**;上传文件只记文件名;字段截断 300 字 / 整段 4000 字。 - 真实 IP:取
X-Forwarded-For首段(Nginx 已透传),remote_addr在反代后恒为 127.0.0.1。 - 界面
/app/audit:员工 / 分类 / 动作 / 起止时间(datetime-local,精确到分钟)/ IP / 关键字 六维筛选,快捷时间 5 档,命中数+涉及账号+时间范围汇总卡,按员工 / 按操作双维度汇总(可点击下钻),明细表 50 条/页、提交参数可折叠展开、状态码 ≥400 标红。 - 导出
/app/audit/export:与列表页共用_audit_filters()保证口径一致,CSV 带 BOM,上限 5 万行。 - 清理
/app/audit/purge:按天数删历史(1–3650,默认 90);POST 先删后记,清理动作本身必然留痕。 - 两个易错点已处理:① 登录失败无会话 → 在
login()里显式记录,并置g._audit_done防止 after_request 重复记一条;② 登出必须在session.clear()之前记录,否则日志里登出的人永远是「(未登录)」。
二、10 个破坏性路由由 GET 改为 POST(安全修复)
审计上线后做路由方法审计时发现:8 个删除类路由仍是 GET,除了会被爬虫预取 / 浏览器预渲染误触发(破坏性操作),更关键的是它们压根进不了审计日志(GET 只记白名单动作),删除行为完全不可追溯。一并修复:
/categories/delete/<cid> · /products/delete/<pid> · /drafts/delete/<did> · /scenes/delete/<sid> · /clients/delete/<cid> · /project/delete/<pid> · /project/<pid>/scheme/unlink/<sid> · /scheme/delete/<sid> · /scheme/<sid>/room/delete/<rid> · /scheme/<sid>/item/delete/<iid>
配套改了 7 个模板里包裹删除按钮的 <form method="get"> → post;schemes.html 里用 location.href 跳转删除的 <a> 改为 POST 表单 + .lnkbtn.danger 按钮(base.html 补对应样式)。
顺带发现并修复的既有故障:分类删除的表单本来就是 method="post",而路由只接受 GET —— 也就是说「分类管理」里的删除按钮此前一直是 405 点不动的。改路由后恢复正常。
验证(线上):登录失败记 1 条不重复、密码脱敏为 **;新增/删除客户分别记「新增客户」「删除客户」并带对象;编辑方案记「编辑方案 · 方案 #2 · Rio.的智能方案」;导出方案册/BOM 记「导出」;普通浏览不记录;按员工/分类/动作/时间段/关键字筛选与 CSV 导出(22 行,含表头)全部 200;清理 30 天前删掉造的旧数据且自身留痕;14 个后台页面 + 5 个导出路由 + 7 组审计筛选全 200。
事故与恢复(记录在此以免重蹈):冒烟测试时向 /app/settings 发了一个只带部分字段的 POST,而该接口是整行 UPDATE,导致乙方名称 / 联系人 / 电话 / 质保年限 / 上门费 / COS 归档开关全部被清空或置默认值。已从当天 11:13 归档到 COS 的交付物 PDF 文本层里取回原值并还原(张工(匠心智能工作室)/ 张工 / 138-0000-0000 / 质保 2 年 / 试用 3 个月 / 上门费 400 / 归档开启),测试客户数据已清理。教训:对整行 UPDATE 的设置类接口做冒烟测试,必须先用 GET 取回完整字段再原样回写,或干脆不要碰生产设置接口。
同步文档:新增 §3.15;§2.3 权限矩阵加审计行;§3.13 侧栏菜单「员工」组加操作审计;§3.3/3.4/3.5/3.6 删除类路由标注 POST;§4 加 audit_logs。
2026-09-03 · 分享唯一链接 + 完整地址显示 + 复制修复 + 文件名规范化 + 归档卡下移✅
用户诉求:*「方案分享要保留唯一链接 并且能在界面显示完整链接地址,复制按钮复制的并非地址,生成的文档名字要有规划 最好跟项目名有关,交付物归档(对象存储)放在设计页面最下方」*。五项全部实现并上线验证。
一、分享链接唯一化(一个方案 = 一条链接)
- 数据层:
scheme_shares加UNIQUE KEY uk_scheme_share (scheme_id);db.py启动时先执行一次去重 ——
DELETE t FROM scheme_shares t JOIN (SELECT scheme_id, MAX(id) keep_id ... HAVING COUNT(*)>1) k ON t.scheme_id=k.scheme_id AND t.id<>k.keep_id。
- 逻辑层:
scheme_share_create()改为 upsert 语义 —— 已有记录则UPDATE(token 原样保留,只更新有效期 / 显示型号),无则INSERT。 - 轮换入口:分享卡新增「重新生成(作废旧链接)」复选框,仅在已有链接时出现,提交
regen=1才换新 token,提示「分享链接已重新生成(旧链接即时作废)」。 - 实测:连续点 3 次分享 → 表内恒为
1 记录、token 不变;勾「重新生成」→ 仍1 记录、token 变新。
二、界面显示完整绝对地址
新增 app._public_base()(request.url_root.rstrip('/'))传给模板,页面上 <code id="shareUrl"> 直接渲染 http://140.143.142.129/share/<token>。不写死域名,换域名 / 加 HTTPS 后自动跟随。
三、复制按钮真正能复制(根因不在代码路径,在浏览器安全策略)
navigator.clipboard 只在安全上下文(HTTPS / localhost)存在;本站是公网 IP + 纯 HTTP,navigator.clipboard 为 undefined,初版 navigator.clipboard.writeText(...) 直接抛 TypeError 被静默吞掉 → 表现就是「点了没反应」。改为三级降级:
window.isSecureContext为真时用navigator.clipboard.writeText();- 否则用隐藏
textarea+document.execCommand('copy')(非安全上下文下仍可用); - 都失败则自动
Range选中链接文本,提示手动Ctrl/Cmd + C。
成功提示改为「已复制:<完整链接>」回显明文,便于肉眼核对。
四、交付物文件名规范化(与项目名挂钩)
新增 doc_export._safe_name() / doc_export.doc_fname(),规则 <方案名>_<类型>_<方案编号>.<ext>:
- 方案名经清洗(去
/ \ : * ? " < > |、压空白、去首尾句点、截断 40 字),为空则整段省略; - 类型随主体类型切换(
服务协议/服务合同/报价单/方案册); - 编号取
schemes.code,缺失回退S%05d。
实测 6 个产物均为 Rio.的智能方案_服务协议_QLZ-20260815-002.pdf 形式。ASCII 兜底名把连续下划线压成一个(Rio.___________ → Rio._)。同一文件名同时用于下载头、COS key、索引表,三处恒定一致。
五、交付物归档卡移到设计页最下方
scheme_detail.html 中归档卡(id="archiveCard")从原位置移至「房间与设备分布」卡之后。当前卡片顺序:套用报价模板 → 方案分享 → 房间与设备分布 → 交付物归档。
顺带清理:改文件名后 COS 桶里残留 6 个旧名对象(QLZ-..._协议.pdf 等),已按「桶内实际 key − 索引表记录 key」的差集编程清理,桶内现只保留 6 个新名对象。
同步文档:§3.5 新增「方案分享(唯一链接)」小节、§3.7.1 新增文件名规则表、§3.7.3 更新目录结构与卡片位置、§4 标注 scheme_shares 唯一键。
2026-09-08 · 户型图点位按方案数量限流 + 删除真正生效 + 备注折叠 ✅
用户诉求:*「选择设备中已经使用过的设备不要显示」*、*「删除点位后还是会显示」*、*「默认可以无限添加默认主灯/默认射灯」*、*「方案界面设备备注不需要全部展示,过长可以折叠」*、*「使用手册按钮被压成竖排」*、*「后台首页 status=1 这种后台显示去掉」*。
一、删除点位不生效(线上 Bug,前端漏改)
plan_point_delete 路由早已是 methods=['POST'](见「历次重要变更」里的 GET→POST 整改),但 plan.html 的删除 JS 仍在发 fetch(url, {method:'GET'}) → 服务端 405 拒绝 → .finally 里照样把 DOM 节点删掉,于是看起来删掉了,刷新后又回来。修复:改 method:'POST',并校验 r.ok 后 location.reload(),失败弹提示(不再静默假成功)。
二、点位按方案数量限流(服务端 + 前端双保险)
- 前端:
scheme_plan视图里统计每个scheme_item已布点数,达到qty的项从「选择设备」下拉移除(_used[item_id] >= max(1, qty))。删除点位后自动重新出现。 - 服务端:
plan_point_add同事务内SELECT ... (SELECT COUNT(*) FROM scheme_points WHERE scheme_item_id=si.id) placed,placed >= qty时拒绝插入并flash('该设备已按方案数量布点完毕,如需再放请先调大方案里的数量')。 - 效果:×1 的设备只能落 1 个点(默认主灯 / 默认射灯不再能无限加),×3 的仍可落 3 处,语义与方案数量一致。文案同步从「同一设备可标注多个位置」改为「同一设备按方案数量标注(×3 可标 3 处)」。
三、删除点位前留档到审计日志
plan_point_delete 在 DELETE 之前 SELECT 点位快照,调 audit_record(..., detail=json.dumps({pid, drawing_id, label, note, scheme_item_id, product, x_pct, y_pct, point_kind, light_type, watt}))。此前 detail 只采集表单字段,而删除请求没有表单 → detail 为 {},删完就再也查不到这个点位是什么、在哪,无法追溯也无法还原。现在审计日志可完整回放。
四、方案详情页设备备注折叠
scheme_detail.html 两处备注单元格改为 .note-clamp(max-width:220px + text-overflow:ellipsis),原文进 title 悬浮全文;超过 24 字追加「展开 / 收起」链接(toggleNote() 切 .open 换行显示)。
五、按钮不再被压成竖排
base.html .btn 补 white-space:nowrap;flex:0 0 auto,.topbar .actions 补 flex-wrap:wrap;justify-content:flex-end。户型图页顶栏按钮多时(BOM / 报价 / 水电 / 户型图 / 使用手册…)「使用手册」被压成竖排三字的根因是按钮被 flex 挤压后内部换行。
六、后台首页去掉裸 SQL 条件
/app/ 概览卡「在架设备」的 sub 直接写了 'status=1'(SQL 条件漏到 UI)。改为「上架中」。
回归:30 路由 0 失败;verify_h.py 13 项全 PASS(含自建自清的删除验证、限流验证、审计留档验证);深色对比度 4 处 / 浅色 8 处(与上轮持平,无回退)。
⚠ 验证脚本教训(第二次踩):verify_h.py 初版直接删真实点位做验证,删掉了生产方案里「厨房 · 小米水浸卫士」的一个点位且无法还原(当时审计还没留档)。已改为 ZZTEST 临时点自建自清。文档「历次重要变更」里早就记过一次同样的坑(v1 误删真实点位 21),任何验证脚本都不得直接操作生产数据做破坏性验证。
2026-09-08 · 虚拟灯位(户型图自由落点 + 不进设备清单)✅
用户原始诉求澄清:之前"无限添加灯"实际是想要「真实设备仍按 qty 限流(这个没毛病)」+「在户型图页加一类"虚拟灯位"——不进设备清单、不进 BOM/报价、但能自由放置用于设计控制关系和灯光模式」。
用户原话:*「客户不会在我这里买灯,但要设计开关与灯的智能化逻辑,所以要创建很多虚拟的灯;但不想在设备界面添加很多很多灯,因为没有实际成本,也不用做在设备报价表里面」*
实现:
- DB 迁移(db.py):
scheme_points加is_virtual TINYINT NOT NULL DEFAULT 0。 - 数据契约:虚拟灯位
scheme_item_id=0, product_id=0, room_id由用户自选(控图按房间分组需要),point_kind='light'显式写入(_backfill_point_kinds只填空值不会覆盖)。 - 服务端:
plan_point_add读is_virtual,是则跳过 item_id 校验、按room_id直接 INSERT 真实点。plan_point_add同时恢复 qty 限流(非虚拟时placed >= qty拒绝)。 - 前端:
plan.html控制面板加「真实设备 / 虚拟灯位」tab 切换;虚拟面板有「名称」输入 + 「所属房间」下拉 + 解释文案。点位列表里虚拟灯显示「虚拟」徽标(虚线accent-strong)。 - 副作用:
points查询与light_pool查询的JOIN改成LEFT JOIN ... COALESCE(NULLIF(si.room_id,0), pt.room_id),让虚拟灯的 room_name 也能正确归类(不显示"未分配")。
BOM / 报价自动不进:bom.html / quote.html 按 scheme_items 聚合(设备项),虚拟点位 scheme_item_id=0 不会进入聚合。控制关系(scheme_point_links)和灯光模式(_load_modes 按 point_kind IN ('light','switch'))都按 point id,与 item_id 无关,虚拟灯自动支持。
自建自清验证 .deploy/verify_virtual.py(17 项全 PASS,含 4b 临时改 qty 测服务端校验):
- 虚拟灯放 4 次全成功;
is_virtual=1/scheme_item_id=0/product_id=0/point_kind='light'/ 房间归属正确 / 灯型已保存 - 虚拟灯进
light_pool(灯光模式可选)、进点位列表 - 虚拟灯不进 BOM / 报价
- 真实设备恢复 qty 限流:可放 N 个、第 N+1 次服务端拒绝
- 临时改 qty 测完已还原为 1
回归:30 路由 0 失败;深色对比度 4 处 / 浅色 8 处(与上轮持平,无回退)。
2026-09-08 · 控制图灯线重叠 → 阶梯分散 + 控制关系串联分组 ✅
用户诉求:*「1. 单击命名的位置有点重叠,看看怎么放合理 2. 灯线有点重叠 看不清 不容易施工 3. 灯可能有串联的情况,比如开5的键3单击同时控制灯3灯4」*
实现:
- S-1 单击命名位置重叠:控制关系面板
.kp-head,.kp-rowgrid 列宽从32px 38px 1fr 40px 26px改为30px 42px minmax(0,1fr) 46px 48px;.kp-row .pt-num{justify-content:center}。一行文字不再压字。 - S-2 灯线重叠 → 车道模型:原
linkPath(a,b,type)同一开关的多条线全部从同一点出发、共用一条干道、同一中点拐弯 → 重叠成一根线。改为:
- lane 模型:每条线出发先水平/垂直错开一条小「车道」(lane = fan × 9px,fan = idx − (count−1)/2),再走各自的横干或竖干(拐点 t ∈ [0.2, 0.8] 沿主方向分散),最后入户。单条线(count=1)lane=0 退化为原路径,完全向后兼容。
- 新增 linkFan(slot)(统一参数推导:{t, lane, fan})。
- linkPath(a,b,type,slot):水平为主走 M a.x a.y L a.x (a.y+lane) L mx (a.y+lane) L mx b.y L b.x b.y;竖直为主走 M a.x a.y L (a.x+lane) a.y L (a.x+lane) my L b.x my L b.x b.y;虚拟线用弧高倍数 (1 + fan × 0.35) 区分(保持同侧不反向)。
- 新增 linkMid(a,b,type,slot):中点徽标 / 气泡跟线走(否则分散后标签会飘在线外)。
- renderLinks 按 lk.sw 预统计 fanCount / fanIdx,逐条算 slot 传入;模式自环 (isMode) 不参与。
- S-3 串联分组:
renderLinkPanel()按sw|k|a|t分组渲染;组头.kp-ghead显示「键N·单击 → 控制 N 个(串联)」+ 类型标签,组内.kp-row.kp-sub缩进列目标。面板一眼看出「这一组动作同时控多盏灯」。
JS 语法自检:抽取 <script> 块 → Jinja {{ }} 替 0、{% %} 替 /**/ → node --check;本轮 2 个块 0 错误。
关键踩坑(值得记):
- Write 工具会双写反斜杠:之前用
"\n"写 Python 字符串,文件里实际变成\\n,导致后续find()永远 -1。绕开:用 Bashcat > file <<'PYEOF'heredoc(quoted、单引号保留字面量),或用'\n'.join([...])替代反斜杠转义。完整 apply 脚本(直接 heredoc 写出来的版本)才是可靠的;用 Write 写出来的脚本要 grep 检查反斜杠是否被双写。 - 原文件用 SVG 隐式 lineto 续接:竖向分支
return 'M ' + a.x + ' ' + a.y + ' L ' + a.x + ' ' + my + ' ' + b.x + ' ' + my + ' ' + b.x + ' ' + b.y;中间的+ ' '是省略L关键字的「隐式 lineto」(SVG 路径规范允许)。我最初按显式L拼字符串匹配不上,必须先打印实际原文再写断言。 - 车道偏移量必须放在出发方向、不能放在主方向:第一版把
off加在mx/my上,导致灯在开关上方时my跑到开关下方(先下行再上行),路径绕远还反直觉。修正为「出发先错开车道 → 干道 → 拐弯 → 入户」后路径总在两端之间。
生产数据验证(方案 2,开5 id=61 → 88/89/90 三条物理线):3 条 SVG path d 值两两不同、起点同 (116.116, 160.9088)、终点各异。Playwright 截图 /tmp/plan_switch_big.png 直观可见 3 条线从开关出发走 3 条不同车道。改前/改后几何对比页 /tmp/fan_test.html 可随时回归。
回归:30 路由 0 失败;深色对比度 dark 4 / light 8(与上轮持平)。
2026-09-08 · 控制关系 + 灯光模式 独立卡片化 + 按键动作表列宽放宽 ✅
用户诉求:*「这个也太难看了 灯光模式(场景联动),控制关系(控制图) 两个一起独立出来 参考 已标注点位(16)」* —— 之前两块挤在右侧 300px 控制面板底部,按键动作表组头「控制 N 个(串联)」被挤换行(控制 N / 个(串 / 联)),目标名被截成「过道...」。
实现:
- 位置调整:把「控制关系(控制图)」整块(含浏览/控制模式按钮 + 三个下拉 + 绑定到模式 + 已选开关 + linkList)和「灯光模式(场景联动)」整块(含 modeList + 新建模式 + 预览提示)整体下放到户型图下方,作为
.card跨整行铺开,与已标注点位同款独立卡片。三个卡片堆叠顺序:控制关系 → 灯光模式 → 已标注点位。 - 列宽放宽:
grid-template-columns从30px 42px minmax(0,1fr) 46px 48px改为40px 80px minmax(0,1fr) 80px 50px,给目标列充足空间,目标名不再被 ellipsis 截断。 - 组头不换行:CSS
.kp-ghead > span:nth-child(3){white-space:nowrap}让「→ 控制 N 个(串联)」始终一行内显示。 - 去掉高度上限:
linkList移除max-height:230px;overflow:auto(独立卡片有天然空间)。 - 修正 CSS 拼写:原代码
<span style="grid-col:1 / span 2">是无效 CSS(grid-col不存在),应为grid-column,JS 里的 5 处全部改正。 - JS 渲染层微调:
renderLinkPanel内 head/kp-ghead/kp-row 的 5 个子元素各自落到独立列(之前靠grid-col合并的列改为正常 5 列布局),结构清晰,JS 函数本身不动。 - 零回归:所有 ID(
modeView / modeCtrl / ctrlBox / keyNo / keyAct / linkType / selName / linkList / modeList / modeNew / modePreviewHint)原样保留,事件绑定、AJAX、UI 行为完全不变;单条线/空开关/空键提示(renderKeyHoles)自动适配新宽度。
布局顺序(上到下):
- 户型图与设备点位(含 canvas,左 1fr / 右 300px 控制面板含「标注设备」+ 图例)
- 控制关系(控制图)(独立卡片,跨整行)
- 灯光模式(场景联动)(独立卡片,跨整行)
- 已标注点位 + 点位自检(独立卡片,跨整行)
回归:30 路由 0 失败;深色对比度 dark 4 / light 8(与上轮持平)。
2026-09-08 · 「选择方案内设备」下拉改成可搜索 combobox ✅
用户诉求:*「这个选择设备最好能下拉选择+输入搜索」* —— 原 <select id="deviceSel"> 是普通下拉,方案设备一多(10+ 设备常见)就要滚动翻找,对「标注点位」这种低频定位操作效率太低。
实现:
- HTML 结构:把
<select id="deviceSel">整块替换为<div class="cb-wrap">(含 visible input#deviceSelInput+ hidden input#deviceSel+<ul id="deviceList">+<script type="application/json" id="deviceOptions">注入数据)。关键兼容:#deviceSel仍为 hidden input,落点 JS 的deviceSel.value读取完全不动,所有现有路由(plan_point_add/plan_point_delete/...)零改动。 - 数据:每条 item 含
id / label / search / room / pname / brand / model / qty八个字段。search = pname + ' ' + brand + ' ' + model + ' ' + room_name,过滤时search.toLowerCase().indexOf(q) >= 0即可。label用 Jinja~拼接(不是+),避免it.qty为 int 触发TypeError: can only concatenate str (not "int") to str。 - 交互:
- 聚焦展开全量选项;已选中态下再次聚焦 → 自动定位到已选项 + input.select()(文本全选,键入即替换)
- input 事件实时过滤;已选中态下继续输入只取 label 之后的新增字符当查询,之前的 label 不进入关键词(用 selLabel 变量判断)
- ArrowDown/Up 键盘高亮 + Enter 选中;Escape 收起;blur 延迟 150ms 收起(给 mousedown 抢先)
- mousedown(非 click)+ preventDefault() 点选 <li>,避免触发 blur 导致点击失效
- 切「虚拟灯位」tab 调 window._deviceCbClear() 清空 combobox
- 无匹配 → <li class="cb-empty">无匹配项</li>;无设备 → input disabled + 占位文案「方案内设备均已标注完毕(或暂无设备)」
- CSS:
.cb-wrapposition:relative(绝对定位的.cb-list锚点);.cb-listposition:absolute;top:100%;left:0;right:0;z-index:20;max-height:260px;overflow-y:auto,单行 4 列 flex(房间 · 名称 · 品牌型号 · 数量×),.cb-active蓝底白字。 - 两个隐藏 bug(都已修):
1. opts = = [] 写错运算符 + if (window._deviceCbClear) ... 误插到 initAddMode 闭合括号之外导致 IIFE 提前闭合 → JS SyntaxError。统一改用 open() 共用函数 + 严格在 apply() 函数体内调用。
2. <style> 嵌套未闭合(致命)——脚本最初加的 <style> 没被前面的 </style> 闭合,又另起了一个 <style>,浏览器会把第二个 style 当无效 HTML 丢弃。结果 .cb-wrap / position:relative 整段 CSS 全部失效,下拉列表 top:1100px 飘到页面底部看不见。教训:写完 HTML 改动后必须用浏览器或 curl 看一下首屏。
回归:30 路由 0 失败;深色对比度 dark 4 / light 8(与上轮持平);9 项 Playwright 交互全过(聚焦/过滤/键盘↑↓Enter/Escape/选中后重开/选中态续输/切 tab 清空)。
2026-09-08 · 「选配产品」下拉也改成可搜索 combobox ✅
用户反馈(S-5 完成后):截图看到方案详情页「房间与设备分布」卡里的「选择设备」<select> 仍是普通下拉,30+ 设备库要滚动翻找 + 没法关键字搜索 → 改。
实现(app/templates/scheme_detail.html):
- HTML:「选择设备」
<select name="product_id" id="addProduct">→<div class="cb-wrap">(visible input#addProductInput+ hiddenname="product_id" id="addProduct"+<ul id="addProductList" class="cb-list">)。form 提交字段名product_id不变,后端零改动。 - 数据源:复用
ALL_PRODUCTS = {{ products | tojson }}(JS 顶部已有的 JSON 变量),不重复注入。过滤维度三段并联:cat.value(类型)/tag.value(标签)/ 文本(name+brand+model+tags拼小写做indexOf)。 - 交互:
focus/input → render实时过滤;mousedown + preventDefault选中 → hidden 写 id + 列表收起 + input 显示「设备名(品牌 型号)」。切类型 / 改标签自动clear()并隐藏列表(避免「切完类型老选项还飘着」)。 - 关键 bug 修复:「外部点击收起」从
input.blur + setTimeout(hide, 150)改成document.mousedown检测:原方案在 Playwright 真实鼠标事件下不稳,事件链里 mousedown 抢先 blur 不可靠 → 列表提前隐藏 → Playwright 报「element is not visible」 → 选中点击 30s 超时。document 监听只排除「点在 input / list 内」,其他位置都收起。plan.html 同步改了(同模式 bug)。 - CSS:复用同一套
.cb-wrap / .cb-input / .cb-list / .cb-pname / .cb-meta / .cb-tag / .cb-empty,三行 flex 布局。两处<style>块独立闭合,不能合并成嵌套(否则浏览器丢弃全部规则——S-5 教训)。 - 保持原有 cat / tag 控件不动:类型下拉(
#addCat)+ 标签输入(#addTag)继续作为粗过滤维度,文本输入做精细过滤;三段是 AND 关系。 - 简化事件:去掉
input.click监听(focus 已 cover)+ 切 cat/tag 不再 render 只 hide(避免突然弹窗)。
回归:6 项 Playwright 交互全过(元素存在 / 5 关键词过滤 / tag 过滤 / cat+文本组合 / 选中写 hidden=4+input=label+收起 / 切类型 hidden 归 0+input 清空);plan.html 同步后 9 项原测试无回归;30 路由 0 失败;dark 4 / light 8 持平。
2026-09-09 · 画布缩放(zoom)50%~300% 工具栏 ✅
用户反馈:*「图有的场景下比较挤,想要图标跟图能一起有放大缩小功能,放大缩小要有限制,不能无限」* —— 户型图点位密集(用户截图 21 个点位挤在一起)时,标注/查看都困难,需要放大缩小查看细节 + 整体。
实现(app/templates/plan.html):
- HTML:canvas 内插入
#zoomToolbar(绝对定位左上角:− 100% + ⟲四按钮)+#zoomWrap包住所有可缩放内容(planImg / linkLayer / bubbleLayer / .pt)。glowToggle 留在 zoomWrap 外,避免被缩放;canvas 加min-height:360px兜底,图片加载失败时工具栏仍可见。 - CSS:
.zoom-wrapposition:absolute; inset:0; transform-origin:center center; transition:transform .15s ease; will-change:transform。工具栏 999px 圆角 pill 风格,26×26 圆形按钮;font-variant-numeric:tabular-nums让百分比数字位宽稳定不抖。 - JS:
initZoom()IIFE,ZMIN=0.5 / ZMAX=3.0强制 clamp;滚轮围绕光标位置缩放(transformOrigin = '%'跟随光标),避免内容漂移;按钮到边界disabled灰显(setScale(1)时两按钮都启用;ZMAX 时 + 禁用、ZMIN 时 − 禁用);还原 ⟲ 重置 transform-origin 为 center。 - 零影响现有逻辑:zoomWrap 与 canvas 同 layout 尺寸(百分比定位基准不变),
.pt的left:X% / top:Y%仍是相对百分比;renderLinks 的toPx(p) = p.x/100*cw, p.y/100*ch用 canvas.clientWidth/Height(不受 transform 影响),所有连线/SVG/bubbleLayer 坐标计算零改动。拖拽点位时鼠标坐标按 canvas 像素算百分比 = zoomWrap 内百分比——zoom 视觉变化不影响落点精度。 - 滚轮冲突规避:canvas 自带
cursor:crosshair暗示工作区,wheele.preventDefault()+{passive:false}阻止冒泡到页面滚动。
回归:8 项 Playwright 全过(初始 100% / 放大 4 次 → 207% / 缩小 3 次 → 58% / 极限放大 300% 且 + disabled / 极限缩小 50% 且 − disabled / 滚轮 3 次上滚 → 133% / 滚轮 6 次下滚 → 71% / 30 个点位全在 zoomWrap 内 / linkLayer 也在 zoomWrap 内);30 路由 0 失败;dark 4 / light 8 持平。
2026-09-09 · 侧栏折叠状态 FOUC 修复(reload 不再闪)✅
用户反馈:*「每次添加灯位等操作,左边折叠栏都会重新打开并关闭一下,体验不是很好」* —— 添加点位走 location.reload(),reload 后页面先按默认 240px 渲染首帧,底部 JS 才读 localStorage 加上 .collapsed → 视觉上「先展开再折叠」闪一下。
根因:状态判定挂在页面底部的 <script>(DOM 解析后执行),晚于首帧渲染。典型的 FOUC(Flash of Unstyled Content)。
实现(app/templates/base.html):
- 状态源上移到
<html>元素:折叠态类从#side.collapsed改为html.side-collapsed .side(CSS 选择器同步改)。这样在<body>还没解析时就能定下样式。 <head>里加同步脚本(必须在<style>之前,不能 defer/async):
```html
<script>
(function(){
try {
if (localStorage.getItem('sideCollapsed') === '1') {
document.documentElement.classList.add('side-collapsed', 'side-boot');
}
} catch(e){}
})();
</script>
```
side-boot只在首帧禁用过渡:html.side-boot .side{transition:none}。若不加,首帧会看到「240 滑到 64」的 0.2s 动画(比闪一下更难看)。底部 JS 用requestAnimationFrame(() => root.classList.remove('side-boot'))解除,之后用户点击仍有过渡。注意:不能直接写.side{transition:none}(会永久关掉动画)。- 底部 JS 改为幂等同步:
root.classList.toggle('side-collapsed', collapsed)(toggle(name, force)是幂等的),head 脚本万一失败也能兜底;side-boot的移除放在按钮判空之前,保证任何情况下都会解除。 - 兜底:
try/catch包 localStorage(隐私模式可能抛QuotaExceededError)。
回归(Playwright 探针注入 DOMContentLoaded 时刻):
- DOMContentLoaded 时
#sideoffsetWidth = 64(不是 240)→ 首帧即折叠,无闪烁 ✓ - DCL 时 html class =
side-collapsed side-boot✓ - 之后
side-boot已移除 → 点击仍有 0.2s 过渡 ✓ - 点击展开 → 240px + localStorage='0';折叠后 reload → 保持 64px ✓
- 30 路由 0 失败;dark 4 / light 8 持平
未做(可选后续):添加点位本身仍是整页 reload(后端 plan_point_add 是 form POST → redirect)。要彻底消除白屏需:后端加 JSON 分支返回新点位数据 + 前端插入 .pt DOM + 重绘连线 + 重建「已标注点位」表格行 + 更新设备下拉剩余数量。改动涉及前后端多处,风险中等,未在本轮做。
2026-09-09 · 侧栏支持整体折叠(桌面端)✅
用户反馈:*「想要左侧整体可折叠」* —— 左侧 240px 导航栏在 1500px 以下分辨率挤占主区,户型图 / 报表类页面需要更大的工作空间。
实现(app/templates/base.html):
- HTML:
- <a class="brand"> → <div class="brand">(含 <a class="brand-link"> + <button id="sideToggle">折叠按钮</button>),外层是 div 才能塞 <a> + <button> 两个交互元素(HTML5 不允许 a 嵌套 button)。
- .side-section / .nav a / .foot 内文字全部包成 <span class="lbl">(6 处)。关键:不能用 font-size:0 隐藏文本节点,必须包成 span 才能 display:none。
- 折叠按钮用 <svg> polyline 「‹」图标(展开态),折叠态 transform:rotate(180deg) 反向。
- CSS:
- .side 加 transition:width .2s, flex-basis .2s 平滑过渡。
- .side.collapsed:width:64px;flex-basis:64px,.lbl{display:none} 全隐藏文字,.nav a 居中、.tag 隐藏、.side-section 居中。
- .side-toggle 32×32 圆角按钮,hover 反馈;折叠态 margin:8px auto 居中。
- JS:新增
initSidebarToggle()IIFE:
- 启动时读 localStorage.getItem('sideCollapsed') === '1' → 应用 .collapsed 类
- 点击 #sideToggle → toggle('collapsed') + localStorage.setItem('sideCollapsed', '1'/'0')
- try/catch 包住 localStorage(隐私模式下可能抛错)
- mobile 端不显示折叠按钮:保留原
#hambhamburger +openNav/closeNav移动端抽屉逻辑,与桌面折叠是两套(折叠只动 width,抽屉动transform:left)。 - 零依赖:纯 CSS + 11 行 JS,零 npm 依赖。
回归:5 项 Playwright 全过(初始 240px / 折叠后 64px / 展开回 240px / localStorage='1' / 刷新后仍 64px);30 路由 0 失败;dark 4 / light 8 持平。
2026-09-03 · 分享 405 修复 + 交付物归档到对象存储(COS)✅
用户诉求:*「方案设计 分享报错 Method Not Allowed」*、*「希望生成的方案可以保存到对象存储中特定的目录中」*。
一、分享 405(线上 Bug,已复现并修复)
根因是两个独立缺陷叠加,都已修复:
- 405 Method Not Allowed —— 方案列表页
/app/scheme的「分享」是<a href="/app/scheme/<id>/share">,但路由scheme_share_create只注册了methods=['POST']。GET 撞 POST-only 路由 → 405。修复:改为行内 POST 表单按钮(.lnkbtn样式与相邻 chip 一致,沿用td.ops .op-derived a的视觉),并加二次确认。 - 404(链接前缀错误) ——
scheme_detail.html与 flash 消息生成的链接是{{ base }}/share/<token>(即/app/share/<token>),而分享页实际注册在根路径/share/<token>。修复:统一生成正确的短链/share/<token>;同时给路由加@app.route(BASE + '/share/<token>')别名兜底,历史/误拼链接也能打开。
复现与验证(curl + 登录会话):修复前 POST 路由 GET 访问=405、/app/share/<token>=404、/share/<token>=200;修复后分享 POST=302、/share/<token>=200、/app/share/<token>=200。
后续修正:本轮附带的「复制」按钮初版用navigator.clipboard,在公网 IP + HTTP 下不可用(undefined),已在同一天的下一条变更记录中改为三级降级方案,详见「分享唯一链接 + … + 复制修复」。
顺带修复同类隐患:户型图点位删除 /plan/point/delete/<pid> 原本是 GET 链接,<a> 会被爬虫预取 / 浏览器预渲染误触发破坏性操作。已改为 POST 表单,路由加 methods=['POST']。审计了全部 23 个 POST-only 路由与所有模板链接,未发现其他同类问题。
二、交付物归档到 COS(新功能)
- 目录结构:
<前缀>/<方案编号>/<方案编号>_<类型>.<ext>,默认deliverables/QLZ-20260815-002/QLZ-20260815-002_协议.pdf。前缀可在系统设置调整,支持中文目录名(实测test-tmp/客户交付/...上传成功,验证后已清理)。 - 触发方式:① 导出三路由(§3.7.1)下载时旁路自动归档;② 新增
/scheme/<sid>/archive(POST)批量归档 6 个产物。 - 幂等:
scheme_files唯一键(scheme_id, kind, fmt)+ON DUPLICATE KEY UPDATE,重复导出覆盖而非堆积。实测导出两轮后索引仍为 6 行。 - 默认私有:交付物含客户 PII,
cos_archive_acl默认private,下载走/file/<fid>/download经登录校验后服务端代理,桶地址不暴露、中文文件名正确;切public-read时直接 302 跳 COS 直链。 - 删除安全:先删 COS 对象再删索引,COS 失败则不删索引,不留孤儿记录。
- 目录前缀清洗:
re.sub(r'[^\w/\-]+', '', prefix)+ 合并连续斜杠 + 去首尾斜杠。关键是剔掉.杜绝../路径穿越(实测../../etc/passwd→etc/passwd)。 - 新表 / 新列:
scheme_files;system_settings增cos_archive_enabled/cos_export_prefix/cos_archive_acl。 - 踩坑:COS SDK
StreamBody.read(chunk_size)只返回单个分块(默认 1024 字节),初版代理下载全部返回 1024B 被截断的文件。改为b''.join(body.get_stream(chunk_size=1MB))后修复——必须用迭代器接口,它走iter_content()且能正确处理Content-Encoding。
验证:6 个归档文件下载字节数与直出产物完全一致(cmp 通过,md5 相同);PDF 用 pymupdf 打开确认 5/1/2 页、标题为「全屋智能家居 服务协议」;COS 桶 list_objects 确认 6 个对象路径正确;删除测试后 COS 对象数 6→5,重新批量归档恢复;全站 9 个关键页面 + 导出路由全部 200。
2026-09-03 · 合同改「个人服务协议」+ 条款去专业化 + 分享链接修复 ✅
用户诉求:*「合同信息不用这么专业,我这边不是公司而是个人;分享的问题直接修复掉。」*
- 新增
provider_type(乙方主体类型):system_settings.provider_type,1=个人 / 工作室(默认)、2=公司。doc_export._load_company()一次性返回kind与全部lbl_*标签,驱动三类产物 + 文件名的措辞切换,完整对照表见 §3.7.2。
- 个人:全屋智能家居服务协议 / 乙方(服务方)/ 服务方姓名 / 乙方(签字)/ 「经双方签字后生效」/ 文件名后缀 协议。
- 公司:全屋智能家居服务合同 / 乙方(施工方)/ 公司全称 / 乙方(签字 / 盖章)/ 「经双方签字(盖章)后生效」/ 文件名后缀 合同。
- 个人模式下身份证号、联系地址留空则整行不出现在协议上(_contract_info() 按 kind 与值双重判断),避免个人主体被迫披露隐私信息。
- 合同去专业化:原 7 章法务体(甲方 7 条 + 乙方 10 条、4.1.1 三级编号、20% 解约违约金、争议解决管辖等)精简为 7 章白话版:服务内容 / 协议金额 / 付款方式 / 工期与验收 / 双方责任 / 质保与售后 / 其他约定。编号降为两级,双方责任改用(1)(2)(3)(4),删除「争议解决」「管辖法院」等对个人服务方不适用的条款。
- 消除 Word / PDF 内容漂移(结构性修复):抽出
_contract_terms(data)与_contract_info(data)作为两条渲染链的唯一内容源(原先 Word 分支和 PDF 分支各抄一遍,历史上出现过 Word 版残留参考文档品牌名、PDF 版没有的问题)。条款内所有「合同」字样改为%s占位,由co['lbl_doc']注入「协议 / 合同」,全篇无硬编码。 - 分享链接修复(线上问题):
/share/<token>经 Nginx 返回 404,根因在 Nginx 层而非 Flask(curl 127.0.0.1:5000/share/<token>= 200/10610 字节,curl 140.143.142.129/share/<token>= 404/138 字节命中默认兜底 vhost)。新增/www/server/panel/vhost/nginx/extension/140.143.142.129/share_proxy.conf:
```nginx
location ^~ /share/ {
proxy_pass http://127.0.0.1:5000; # 末尾绝不能带斜杠,否则剥离 /share 前缀
proxy_set_header Host $host;
...
}
```
nginx -t 通过后 reload。修复后经外网验证 200 / 10610 字节 / <title>Rio.的智能方案 · 智能方案</title>。未改动 Flask 代码,已生成的分享链接全部继续有效。
- 验证:6 条导出路由(contract / quote / scheme × pdf / docx)全部 200;个人模式 Word 标题
全屋智能家居服务协议 | 协议编号:QLZ-20260815-002,章节为['一、服务内容','二、协议金额','三、付款方式','四、工期与验收','五、双方责任','六、质保与售后','七、其他约定','八、签署'];公司模式单独验证后再切回个人模式。pymupdf 抽文本层全文检索,残留「本合同」「合同编号」「合同正本」均为 0。 - 线上配置:乙方记录已切为个人模式——名称
张工(匠心智能工作室)、电话138-0000-0000、地址与身份证号留空、warranty_years=2、trial_months=3、service_call_fee=400。
2026-09-03 · 最终交付物重做:合同 / 报价单 / 方案册(对齐「松下智能」合同版式)✅
- 诉求:以《松下智能》购销质保合同为参考,重做合同、报价单、方案册三类最终产物;先出方案再确认。四项决策(已与用户确认):改造范围=全改;乙方信息=后台可配置;合同编号=
scheme.code;报价单位置=合同末页同一份(附件一)。 - 新增:
- system_settings 表(单记录 id=1)+ /app/settings 系统设置页(admin),见 §3.14。合同所有乙方信息与质保/试用/上门费参数全部从这里读,doc_export 不再硬编码公司名。
- schemes.code 字段(唯一键),格式 QLZ-YYYYMMDD-NNN,新建方案时由 app._next_scheme_code(c) 生成,历史方案已按创建日期回填。合同编号 / 报价单号 / 方案册号统一取它。
- 合同 7 章正文 + 封面 + 签章页 + 附件一报价单合并到同一份 PDF/Word;报价单含中文大写金额(_cny_capital,如「肆仟柒佰肆拾柒元壹角叁分」);PDF 页眉(品牌 + 合同编号)页脚(第 X 页 / 共 Y 页,两次 build 探测总页数),封面页不印页眉页脚。
- 修复的缺陷:
- _pdf_table 右对齐误用 alignment=i+1(列序号),正确值应为 TA_RIGHT=2;传非法值会让 reportlab 内部 paragraph.drawPara 抛 UnboundLocalError: dpl。
- _pdf_h1/_pdf_h2 重复传 fontName 与 _pdf_style 内置值冲突 → TypeError: got multiple values for keyword argument 'fontName'。
- 下载头拼中文文件名 → gunicorn 以 Invalid HTTP Header 拒绝,Word 导出变 502。改为 RFC 5987(filename*=UTF-8''… + ASCII 兜底)。
- 合同 5.3.4 质保期硬编码「一年」,与系统设置的 warranty_years=2 自相矛盾 → 改为同源取参。
- 抄写参考文档残留的「松下智能家居产品」字样(4.1.5)与 4.2.6 主语错误(「乙方应通知乙方」)→ 已订正为「智能家居产品」「甲方应至少提前七日书面通知乙方」。
- Word 版 6.2 / 6.4 出现 30%% 双百分号(字符串未走 % 格式化却写了转义)→ 改回单 %。
- 验证:6 条导出路由(contract / quote / scheme × pdf / docx)经 Nginx + 登录会话全部 200;合同 PDF 6 页、报价单 1 页、方案册 2 页,文本层可提取、无残留品牌与格式化痕迹。
- 已知限制:参考件
松下智能(1).pdf为纯图片扫描件(8 页无文本层),当前模型无法读图,故章节结构是按标准购销质保合同惯例拟定的,而非逐条照抄原文。如与实际文本有出入,需用户指出后逐条对齐。
2026-09-03 · 分类管理:改名 / 合并转移 / 删除保护 ✅
- 诉求:分类名称要能随时调整,且已经绑了设备的分类要能动态改挂(不是只能干瞪眼)。
- 背景风险(已修):原
category_delete直接DELETE FROM categories WHERE id=%s,不检查设备引用。线上 10 个分类里 9 个挂着设备(安防守护 4 台、暖通 HVAC 4 台、智能开关 5 台…),误删会让这些设备的category_id变成悬空值,列表LEFT JOIN后显示「未分类」且按分类筛选失效。分类 id 缺失的 10 号缺口很可能就是历史误删造成的。 - 改动:
- app.py:
- 新增 _descendant_ids(conn, cid) —— 迭代式递归取子孙 id 集合,供环检测用。
- categories() POST 分支:加同级重名校验(name + parent_id 唯一性,因表无唯一约束只能应用层拦)、环检测(上级不得是自己或子孙)、操作结果 flash 回执(此前无论新增还是修改都静默跳转,用户无从得知是否生效)。
- categories() GET 分支:SQL 改为子查询带出 product_count 与 sub_count。
- category_delete():查设备数与子分类数,非空则 flash 拒绝并指引「先用合并到…」,空分类才删。
- 新增 category_merge():UPDATE products SET category_id=dst WHERE category_id=src → UPDATE categories SET parent_id=dst WHERE parent_id=src → DELETE 源分类,三段包在 conn.begin()/commit()/rollback() 事务中(get_conn() 是 autocommit=True,必须显式 begin() 才有原子性);校验 src != dst、两者存在、dst 不在 src 子孙集合内。
- categories.html 重写:表格加「设备数」列(可点跳转 products?cat=<id>)与「操作」列(编辑 / 合并到… / 删除);删除按钮在非空时 disabled + title 说明原因;每行下挂隐藏的合并面板(src_id hidden + 目标分类 select + 二次确认);底部表单支持编辑态回填(startEdit/resetForm/toggleMerge/confirmMerge 四个函数),编辑时上级下拉禁用自身项。
- 验证(服务器真实库上跑,测前快照备份、测后逐条比对还原):11 项断言 10 项一次通过,1 项为测试脚本断言字符串写反(实际文案「并把 1 个子分类提升到…」正确)。覆盖:页面渲染含新元素、改名落库、设备库筛选页同步显示新名、改回原名、同级重名被拦且不写入、非空分类删除被拒且设备数不变、合并到子孙被拒、正常合并(2 台设备迁移 + 子分类提升 + 源分类删除 + 提示含数量)、合并后设备库按新分类筛出、非管理员合并被拦 302、空分类可删。
- 数据安全性:全部破坏性用例都在
__T前缀的临时分类/设备上跑,测完DELETE ... WHERE name LIKE '__T%'清理,最终校验「分类与备份一致 = True」。期间用户在浏览器新增的 3 台真实设备(id 31/32/35)未受任何影响。
2026-09-03 · 设备图片支持粘贴 / 拖拽上传 ✅
- 诉求:新增/编辑设备时只能点「选择文件」上传图片,操作繁琐,希望能直接
Ctrl+V粘贴截图。 - 改动:
- app.py 新增 POST /app/products/upload(@admin_required):接收 multipart image,复用 cos_client.upload_image(f, folder='products') 落 COS,返回 JSON {ok,url};失败返回 {ok:false,msg} + HTTP 500。仅传图不写库,URL 由表单保存时落 products.image_url。
- product_form.html 图片区重构为虚线 dropzone(原裸 <input type=file> 保留但隐藏):三种入口(全局粘贴 / 拖拽 / 点击选择)+ 即时预览 + 「移除图片」+ 上传中/成功/失败状态。
- 粘贴实现要点:监听 document 的 paste,遍历 clipboardData.items 找 kind==='file' && type 以 image/ 开头的项,getAsFile() 取 Blob 后才 preventDefault()——因此纯文本粘贴完全不受影响。无文件名时按 MIME 补扩展名。
- base.html 新增 {% block head %}{% endblock %}(置于 </title> 后、主 <style> 前),供子模板注入页面级 CSS;对既有页面无影响(空 block)。
- 验证:
python3 -c "import ast"校验app.py;node --check校验内联 JS;url_map确认/app/products/upload已注册;服务器端 test_client 确认页面含imgDrop/imageUrl/products/upload,未登录上传返 302;经真实 gunicorn(systemd 注入 COS 凭证)curl 登录 + 上传返200 {"ok":true,"url":"...cos.ap-nanjing.myqcloud.com/products/486a86c8....png"},该 URL 公网 curl 得HTTP 200 image/png。 - 已知限制:COS 凭证依赖 systemd
Environment(QLZ_COS_SECRET_ID/KEY),直接用venv/bin/python跑 test_client 会因缺环境变量报「COS 凭证未配置」——这是测试进程的问题,正式服务不受影响,排查时勿误判。
2026-08-19 · 侧栏菜单分级(物料设备 / 场景方案 / 项目交付 / 员工)✅
- 诉求:分类管理并入设备库、上下游关系清晰;一级菜单 12 项太平,无法一眼看出层级。
- 改动:
- app.py NAV 每项加 group 字段(True = 分组标题、False = 普通菜单、None = 无分组);增 admin_only 字段(5 项 admin 独占:设备库/分类/采集/场景/员工),非管理员自动隐藏分组与菜单。
- 分组顺序:物料设备(设备库·分类·采集入库)→ 场景方案(场景管理·方案设计)→ 项目交付(客户·项目·报价·BOM·施工图)→ 员工。
- base.html 模板循环里判断 {% if n.group %} 渲染 <div class="side-section"> 不可点击分组标题,否则渲染原 <a>。
- CSS .side-section 浅灰小字 11px / letter-spacing 2px / 顶部分隔线(首个分组免)。
- 验证:服务器端
test_client注入session['user']='admin',确认分组标题 4 个 + 12 菜单;用yanghuilong(designer)测得分组标题 3 个(员工组隐藏)+ 7 菜单(admin_only 5 项隐藏),可见性差集合为{products, categories, drafts, scenes, users}。 - 下游影响:所有 13 个内页继续沿用
nav_for('current'),不需要改路由或模板继承,仅 base.html + NAV;公开首页/quiz.html不受影响。 - 新章节:§3.13「侧栏菜单层级」明细分组对照表 + 业务逻辑层、模板层、样式层说明。
- 取舍:智能报价/BOM/施工图严格说是方案产出物,本次未下放到方案设计内(避免一次重构过大,按用户选择"先不合并"保持独立入口)。
2026-08-18 · 在线功能展示页 /docs 上线 ✅
- 新增
gen_docs.py(Markdown → 静态 HTML 渲染器)与docs/index.html。 - 部署到
/www/wwwroot/quyuzhineng/docs/index.html,外网http://140.143.142.129/docs/可访问(含侧边目录、表格、返回首页/后台链接)。 - 约定:
功能文档.md为单一事实源,改文档后运行gen_docs.py重新生成并上传 HTML。
2026-08-18 · 修复方案列表「设备项」显示 <built-in method items of dict object> ✅
- 根因:
schemes.html用{{ s.items }}渲染,Jinja2 优先取dict.items方法而非items键,导致返回方法对象。 - 修复:改为
{{ s['items'] }}/{{ s['rooms'] }}(索引语法强制按字典键取值)。 - 影响:方案列表的「设备项」「房间」两列恢复正常显示。
2026-08-18 · 通信协议改为多选(复选框 + 手输)✅
- 表单「通信协议」由
<datalist>单选改为复选框(12 项常用协议)+ 「其他」手输框。 - 提交时 JS 合并两项并去重(按小写不敏感),写入隐藏字段
protocol。 - 编辑时 JS 回填:精确 / 前缀匹配的预设项自动勾选,未匹配项还原到「其他」框(向下兼容老数据如
Zigbee 3.0)。 - 保存格式仍为
,分隔字符串,与数据库 schema 兼容;影响设备库 / 搜索 / 方案设计全链路。
2026-08-18 · 功能文档建立(基线)
2026-08-18 · 设备库标签 + 协议可选 ✅
products表新增tags列(应用启动自动迁移)。- 设备列表新增标签搜索框与「标签」列,标签可点击跳转
?tag=xxx实现二次筛选。 - 设备表单新增「标签」输入框(逗号分隔);
protocol改为<datalist>可选下拉(可自由输入新协议)。
2026-08-18 · 方案设计:添加设备置顶 + 类型/标签过滤 ✅
- 「添加设备」表单移至「房间与设备分布」面板顶部。
- 交互改为:先选类型 → 选具体设备 → 按标签过滤(前端 JS,数据经
{{ products|tojson }}注入)。 - 移除原底部旧设备表单,仅保留「+ 房间」。
2026-08-18 · 选配向导 · 进度条可点击跳转 + 底部按钮固定吸底 + 联网徽标 ✅
- 进度条可点击跳转:1–16 编号圆点对已访问过的步骤(
history ∪ {cur})自动激活为可点击(绿色描边 + cursor:pointer + hovertransform:scale(1.18)),点哪跳哪且保留history,再点底部「上一步」可回到此前停留位置;未访问的步骤保持中性灰(避免误跳空白)。 - 底部按钮固定吸底:
.actions加position:sticky; bottom:0; background:var(--surface); padding:14px 24px; border-top:1px solid var(--line); box-shadow:0 -2px 10px rgba(20,30,50,.05); margin:-24px对齐到.panel左右边缘;用户往下滚看长描述时按钮始终可见。 - 联网徽标:把顶部
DEMO · 评审版(纯前端,不落库)改为绿色● 已联网版 · 提交后自动存入后台(.live-tag样式:绿底 + 绿文字 + 7px 高亮圆点带 18% 透明光晕),与"已接后端"事实一致、消除"还是 demo"的歧义。 - 验证:JS 语法 OK;scp 部署;线上
live-tag/jumpTo/clickable命中 9 处、demo-tag/纯前端/评审版命中 0 处。
2026-08-19 · 选配向导 10 处文案/流程优化(contact 直接落库 + 结果页去后台入口)✅
- contact 步直接落库:原流程是 contact 步只存数据到
answers['__contact__'],跳到结果页再点「生成正式方案(存后台)」二次触发fetch /quiz/submit。改为 contact 步按钮「保存方案 →」直接调 fetch,成功 →window.__QLZ_SCHEME__ = {scheme_id, code, name, url}+ 跳结果页;按钮显示「保存中…」loading。 - 结果页去后台入口:删除「⬆ 生成正式方案(存后台)」按钮、「打开后台方案 →」链接、「🔒 仅管理员可见」段落、
#submitResult容器及submitBtn.onclick全部逻辑;改为顶部绿色 ✓「方案已保存」横幅(编号 + 名称)。如 contact 步未提交(用户跳过 contact),显示黄色 alert 提示返回或下载 .txt。 - 多选题 0 选也能下一步:所有
type:'multi'页(curtain_rooms / sensor_type / security / hvac / av_type / scenes)按钮默认disabled取消,移除arr.length===0的回写逻辑;语义对齐 hint「可多选,不需要可不勾」。 - 文案打磨:删除第 2 步 hint「智能开关改墙上的开关…二选一或都要」;第 3 步问句「怎么搭配最聪明」→「哪种搭配更合适」;第 4 步移除「电动开窗器」项与「投影幕布」建议(不属于窗帘),新增「餐厅」项;第 5 步 hint「做安防与自动」→「做安防或自动控制」;第 10 步「门窗磁」→「门窗磁传感器」(label + 建议 + 落库映射三处同步)。
- 验证:node --check OK;scp + kill -HUP 82911;curl 命中关键文案「方案已保存/保存方案/保存中/门窗磁传感器/做安防或自动控制/哪种搭配更合适」,「提交中/打开后台方案/仅管理员/投影幕布/电动开窗器」命中 0;服务器端 test_client 三组测试 — 正常提交返回 scheme_id=12/13 + code,缺称呼电话返回 400。
- 背景:原
products只有单一price(单价),BOM 与报价的设备金额是同一个数,区别仅来自方案级设计/施工费率,无法体现成本/售价差异。 - DB 迁移:
products表幂等新增cost_price DECIMAL(10,2) NOT NULL DEFAULT 0(采购成本价),保留price为对外售价。 - 设备录入:
/products/form新增「采购价(元)」输入框(带说明,留空按 0);POST 解析 + UPDATE/INSERT 同步;批量导入/products/import解析可选列cost_price/采购价,模板/products/import_template表头与示例同步新增该列;/drafts上架入口不填、自动 0。 - BOM 改成本口径:
/bom+bom.html+bom_export改用p.cost_price核算,表头「单价」→「采购价」、合计行「采购总成本」;未填采购价设备显示「—」并顶部提示补全;CSV 导出表头同步。 - 报价加毛利:
/quote计算cost_total=Σ(qty*cost_price)、gross_profit=设备售价小计−成本;quote.html在费用区新增「采购成本(设备)」「设备毛利(含毛利率)」两行(仅当方案设备均填采购价时显示)。设备明细表保持售价price。 - 演示数据:给未填采购价的 20 个设备按售价 70% 预设示例采购价(
UPDATE ... WHERE cost_price=0 AND price>0,可逆),使方案 2(8 设备,采购成本 2940.70)立即可见 BOM 与报价的区别;用户可到设备库逐条改为真实采购价。 - 验证:本地
ast.parse通过;scp 部署 +kill -HUP 82911;服务器端 test_client 确认cost_price列已生成、/app/bom?scheme=2与/app/quote?scheme=2均 200 且分别含「采购总成本/采购价」「采购成本/设备毛利」;方案 1 无设备(符合预期)。
2026-08-18 · 选配向导 · 「我要灯组」依赖无线模式 + 排版修复 + 去除 Demo 角标 ✅
- 「我要灯组」是「智能灯 + 智能开关 无线模式」才有的玩法,UI 默认 disabled 并显示 ⚠️ 提示「请先在上面勾选『智能灯 + 智能开关(无线模式)』」;勾无线模式后自动启用,取消无线模式则自动取消灯组并再次 disable。
- 排版修复:原
@media(max-width:680px)拆成两层 —— ≤900pxcombo-stage{1fr}(lamp/ctrl 上下 stack,避免横排被挤到 emoji 截断)+ lamp 改横排(💡+状态文字)+ slider-row 允许换行;≤480px 进一步压缩灯到 48px、卡片 padding 收紧。 - 二改:
combo-grid默认从 2 列改为 1 列(仅 ≥1100px 桌面端才 2 列),解决手机/平板横屏下 viewport 报告 ≥900px 触发不到单列规则、导致手机(在线)/色温/80%被压成单字符竖排的问题。 - 三改(防横向滚动):手机打开整页左右滑的根因是顶部进度条 16 个
flex:0 0 auto圆点固定不收缩(每 30px=480px),窄屏横向溢出。修复:窄屏圆点缩小(≤900px 22px / ≤480px 18px)+ 收紧.prog/.pseg间距;.wrap加overflow-x:clip兜底(不建滚动容器、不影响 sticky 吸底)。 - 去除 Demo 字样:
<title>/ 联系方式 hint / 结果页脚注全部不再写 Demo,统一改成"提交后自动生成方案草稿并绑定客户"和"方案已存后台并绑定客户"。 - 验证:JS 语法 OK;scp 部署;线上
/quiz.html标题无 Demo、groupChk 默认 disabled + 提示文案可见、新断点max-width:900px存在、Demo 字样 grep=0。
2026-08-18 · 选配向导 · 照明控制交互演示 ✅ 已上线
「③ 照明控制 · 形式」步骤改为可交互卡片,每张卡片包含:
- 💡 灯泡(可视化):发光色随色温滑块实时变化(暖光→冷光,HSL 色相 35–210);亮度滑块控制透明度和发光强度。
- 🔌 墙开关按钮:开/关切换,影响灯状态。
- 📱 手机控制面板:开关 / 色温 / 亮度三个滑块;普通灯搭配下色温/亮度自动灰显(普通灯不支持调光调色);灯离线时所有控件灰显。
- 三种搭配:
- 智能灯 + 普通开关 — 墙关=断电→灯离线,手机全失控(不推荐)。
- 普通灯 + 智能开关 — 手机可开关但无法调光调色(普通灯物理上只能单色)。
- 智能灯 + 智能开关(无线模式)⭐推荐 — 墙开关=无线信号不切电源,灯始终在线;色温/亮度随时可调。
2026-08-18 · 选配向导 · 灯组交互演示 ✅ 已上线
本步骤末尾灯组精髓玩法重做:
- 三盏同房间灯卡片(客厅 · 筒灯一圈 / 客厅 · 灯带氛围 / 客厅 · 主灯):每盏含灯泡视觉 + 单灯开关按钮,可独立切换(符合「一整圈筒灯+灯带做氛围」的真实场景)。
- 全屋手机控制面板:总开关 + 色温滑块 + 亮度滑块,整组同步联动;总开关关掉时所有灯离线灰显。
- 场景按钮(5 个):🚪 入户门一键关所有 / 🛋️ 主灯一键关(留氛围)/ 🏠 回家(全开 暖光 50%)/ 🎬 观影(关主灯 30%)/ 💡 恢复(默认)。
- 动画:单灯切换有
.blink收缩-恢复动画;多灯联动有.fadein渐入;按钮按下时即时视觉反馈。灯的颜色由全屋滑块决定的 HSL 色相驱动,整组保持视觉一致。 - CSS 动画:
@keyframes lampBlink(scale + brightness)、@keyframes lampFade(opacity),transition:.3s ease平滑 HSL 变化。
2026-08-18 · 选配向导 · 正式上线对接后端 ✅ 已上线
- 新增公开接口
POST /app/quiz/submit:问卷结果落地为后台方案草稿并绑定客户(详见 §3.12 后端对接说明)。 - 前端结果页新增「⬆ 生成正式方案(存后台)」按钮,调用接口成功后展示方案编号 + 后台查看链接。
- 防护:蜜罐
company字段、缺称呼/电话返回 400;scheme_detail改LEFT JOIN+price or 0兼容推荐项(无真实产品)。 - 端到端验证通过(无登录提交、蜜罐静默、缺信息 400、明细页正确渲染 8 房间 15 设备),测试数据已清理。
本步骤末尾灯组精髓玩法重做:
- 三盏独立灯卡片(玄关/客厅/卧室):每盏含灯泡视觉 + 单灯开关按钮,可独立切换。
- 全屋手机控制面板:总开关 + 色温滑块 + 亮度滑块,整组同步联动;总开关关掉时所有灯离线灰显。
- 场景按钮(5 个):🚪 入户门一键关所有 / 🛋️ 客厅一键关灯 / 🏠 回家(暖光 50%)/ 🎬 观影(客厅 30%)/ 💡 全开(恢复原貌)。
- 动画:单灯切换有
.blink收缩-恢复动画;多灯联动有.fadein渐入;按钮按下时即时视觉反馈。灯的颜色由全屋滑块决定的 HSL 色相驱动,整组保持视觉一致。 - CSS 动画:
@keyframes lampBlink(scale + brightness)、@keyframes lampFade(opacity),transition:.3s ease平滑 HSL 变化。
历次重要变更(归纳)
- 方案在线分享(
/share/<token>公开只读预览)。 - 报价模板库(一键套用快速出单)。
- 多员工权限(5 角色 +
staff_required,20 条业务路由由 admin 降为 staff)。 - 智能施工图(4 类:水电预留 / 网络布线 / 开关面板 / 木作灯光,含设备施工图属性)。
- 性能优化(仪表盘/方案详情/自动填充的 N+1 查询修复;移动端抽屉导航;密码重置与启停用的二次确认)。
- 仪表盘数字滚动动画。
- 公共首页 L1–L4 可交互动画 + 深色/浅色主题切换;L3/L4 用具体米家设备链路呈现。
- 全屋方案统一为仅米家(静态页 + 后端
scenes库同步修改)。
2026-08-18 · 公共首页加 CTA 入口卡片(引导至选配向导)✅
index.html在 caveat 之后新增三色渐变 CTA:「还在纠结该选哪一级?让家自己『说』出需要什么」+ 「3 分钟智能选配 / 量身生成方案 / 一键转交设计师」,按钮「开始配置我的全屋智能 →」跳/quiz.html。- 视觉独立于当前 L1–L4 配色(固定绿→蓝→紫渐变),作为常驻行动召唤;移动端按钮自适应全宽。
- 已 scp 部署到
/www/wwwroot/quyuzhineng/index.html,外网http://140.143.142.129/验证 200 包含class="cta"。
2026-08-18 · 选配向导 · 复制方案名 + 后台调整提示 ✅
- 「复制方案文本」→「复制方案名」,只复制
方案编号 + 方案名称(如QLZ-20260818-SU8U zzz的110㎡ 平层 智能家居方案(L3 全屋智能)),按钮成功反馈改为✅ 已复制方案名1.4s 后还原;不再复制整段方案文本,避免把没必要的完整清单发到客户档案。 - 结果页「打开后台方案 →」按钮下方加 🔒 提示行:「仅管理员可见:登录管理后台后可调整设备清单、报价、BOM、施工图,并生成分享链接给客户」,并附「管理后台」直链
/app/。 - 明确分工:选配向导
/quiz.html客户自助入口 → 提交落库为方案草稿 → 管理员从/app/scheme/<id>进入方案详情完成「调整 → 二次生成(BOM/报价/施工图)→ 分享给客户(/share/<token>)」闭环。详见 §3.12。 - 已 scp 部署到
/www/wwwroot/quyuzhineng/quiz.html,外网http://140.143.142.129/quiz.html验证 200 含复制方案名/仅管理员可见/管理后台。
2026-08-18 · 选配向导 · 后端 scheme_rooms 与选配向导 1:1 对齐 ✅
- 问题:图1 后台方案详情「房间与设备分布」只看到客户实际勾选的中枢/照明/传感 3 类,缺「网络方案」「目标场景」两段,设计师看不到也没法调整。
- 修复:
app.py:quiz_build()在最优先位置追加{'c': '网络方案(地基)', 'd': [network]}、在最末位置追加{'c': '目标场景', 'd': ['、'.join(scenes)]},保证选配向导 6 大段(网络→中枢→照明→传感→安防→暖通→场景)在后台一一对应。 - 端到端验证:新方案 id=8 的
scheme_rooms含 5 个 room(网络方案(地基)/ 控制中枢 / 照明控制 / 传感感知 / 目标场景),scheme_items.note推荐文案与选配向导前端结果完全一致;/app/scheme/<id>模板渲染 200 含所有 5 个 group 标题与文案。 - 已 scp 部署
app/app.py→/www/wwwroot/quyuzhineng/app/app.py,kill -HUP 82911热重载;外网再次提交问卷 200 OK。
2026-08-18 · 方案设计列表页整体优化(对齐 + 摘要 + 筛选)✅
- 问题:原
/scheme列表「操作」列 7 个按钮(设计/户型图/BOM/报价/水电/编辑/删除)挤在单行溢出表格右边界,无对齐、不可读。 - 改造(
schemes.html+base.html新增样式,纯前端、无后端改动):
1. 操作列三段式:主入口(📝设计 + 📋复制)|工具(户型图/BOM/报价/水电/分享 紧凑 chip)|管理(仅 staff:编辑 + 删除二次确认)。每段可换行、不挤压。
2. 顶部 6 张摘要卡:方案总数 / 启用中 / 停用·草稿 / 总房间 / 总设备项 / 已绑定客户数(前端实时统计)。
3. 筛选工具条:名称/客户/编号实时搜索 + 状态分页签 + 等级分页签(L1–L4)+ 排序(最近更新/编号/面积/名称)+ 「显示 N / 共 M 个方案」计数。
4. 列表项展示方案编号(monospace 小字)+ 方案名称行内可点击进详情。
- 验证:
test_client注入 admin sessionGET /app/scheme200,渲染含schemes-summary/filter-bar/op-primary/op-derived/op-mgmt全部结构;模板 Jinja2 语法校验 OK。已 scp 部署两模板 +chown www:www+kill -HUP 82911。 - 清理:遗留测试方案(id=8 及子表 scheme_items/scheme_rooms、测试客户)已硬删除,列表现仅 3 个真实方案。
2026-08-18 · 方案详情页 · 房间概念分离(room / system / meta)✅
- 用户反馈:方案详情「房间与设备分布」里展示的「控制中枢 / 照明控制 / 传感感知 / 安防守护 / 暖通 HVAC」不是物理房间,是系统功能类,混在一起概念错位。
- 数据改造:
1. db.py 加迁移 ALTER TABLE scheme_rooms ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'room',并对已知系统/元信息名 UPDATE:网络方案(地基)/目标场景 → meta;控制中枢/照明控制/窗帘门窗/传感感知/安防守护/暖通 HVAC/影音娱乐/清洁电器 → system;其余默认 room。
2. app.py:quiz_build() 给每个 group 带 k(meta/system),写入 scheme_rooms.kind。
3. scheme_room_add 路由新增房间一律 kind='room'(防误增)。
- 模板
scheme_detail.html拆三段卡片:
- 🏠 物理房间:色带 banner + 「+ 房间」(datalist 固定 17 项:玄关/客厅/餐厅/厨房/主卧/次卧/儿童房/书房/主卫/次卫/衣帽间/阳台/储物间/车库/影音室/茶室/健身房/其他)+ 「+ 添加设备」(房间下拉仅显示物理房间)。
- ⚙️ 系统配置:色带 banner + 展示选配向导子系统(中控/照明/.../清洁),可移除设备,不可新增。
- 📋 方案元信息:色带 banner + 文字展示(网络方案 / 目标场景),不参与造价统计。
/bom与/quote查询加JOIN scheme_rooms sr ON si.room_id=sr.id WHERE sr.kind<>'meta'(双保险:元信息天然 product_id=0 已被排除,再加显式 kind 过滤)。- 端到端:POST /app/quiz/submit 提交测试 payload → 新方案 scheme_rooms 含 9 条记录,kind 分布:meta=2(网络/场景)、system=7(中枢/照明/传感/安防/暖通/影音/清洁)。/app/scheme/9 渲染 200 含三段 banner;/app/bom?scheme=9 200 OK。测试数据已清理。
- 部署:
db.py/app.py/scheme_detail.htmlscp +chown www:www+kill -HUP 82911热重载;现有 3 个真实方案渲染正常(meta 段空时模板条件隐藏)。
- 「选配产品」下拉改成可搜索 combobox(2026-09-08 需求 S-5-2):
scheme_detail.html房间与设备分布卡片里的「选择设备」<select id="addProduct">(按「全部类型 + 标签」客户端过滤)是个普通下拉,30+ 设备库时滚动翻找 + 没法关键字搜索。改为可输入过滤的 combobox:
- HTML:<div class="cb-wrap">(visible input #addProductInput + hidden input name="product_id" id="addProduct" + <ul id="addProductList" class="cb-list">)。form 提交字段名仍是 product_id,后端零改动。
- 数据源:直接复用 ALL_PRODUCTS = {{ products | tojson }}(JS 顶部已有的 JSON),不重复注入。过滤维度多一个文本(cat + tag + 文本三段并联):name + brand + model + tags 转小写做 indexOf 匹配。
- 交互:聚焦/输入 → 实时过滤渲染 → mousedown 选中写 hidden。切类型 / 改标签 → 自动 clear() + 隐藏列表(避免「刚切类型老选项还飘着」)。点选设备 → 列表收起 + input 显示「设备名(品牌 型号)」+ hidden 写 id。
- 「外部点击收起」改用 document.mousedown 检测:原 input.blur + setTimeout(hide, 150) 方案在 Playwright 真实鼠标事件下不稳(mousedown 抢先 blur 不可靠,事件链里 list 提前隐藏导致 Playwright 报「element is not visible」)。document 监听里只排除「点在 input / list 内」,其他位置都收起。
- CSS:沿用 plan.html 同一套 .cb-wrap / .cb-input / .cb-list / .cb-pname / .cb-meta / .cb-tag / .cb-empty,三行 flex 布局(名称 · 品牌型号 · 标签胶囊)。注:两处 style 块独立闭合,不要合并成嵌套 <style>(否则浏览器丢弃全部规则——上轮 S-5 教训)。
- 回归:6 项 Playwright 交互全过(聚焦展开 / 5 关键词过滤 / tag 过滤 / cat+文本组合 / 选中写 hidden / 切类型清空);plan.html 同步把 blur + setTimeout 改成 document.mousedown,原 9 项测试无回归。30 路由 0 失败,dark 4 / light 8 持平。
6. 部署与维护须知(给后续维护者)
- 改 Python:上传到
/www/wwwroot/quyuzhineng/app/,kill -HUP <gunicorn主进程>热重载。 - 改模板:务必上传到
/www/wwwroot/quyuzhineng/app/templates/(曾误传app/根导致不生效)。 - 改公共首页:覆盖
/www/wwwroot/quyuzhineng/index.html,并保留index.html.bak作回滚。 - 改功能文档 / 在线展示页:编辑
功能文档.md→ 本地运行python3 gen_docs.py生成docs/index.html→ 上传到/www/wwwroot/quyuzhineng/docs/index.html(chown www:www)。单一事实源是功能文档.md,docs/index.html仅为渲染产物,勿直接改 HTML。 - 数据库修改:尽量走
db.py启动迁移(try/except重复加列忽略),避免手工 ALTER 遗漏。 - 验证:本地 Mac 的 5000 端口被 AirTunes 占用,所有 Flask 接口验证须在服务器内
curl 127.0.0.1:5000/app/...。 - 文案约束:任何对外描述只出现米家,不写其他生态。