全屋智能平台 · 功能文档

全屋智能 SAAS 平台 · 功能文档

单一功能事实源(Single Source of Truth)

0. 文档约定


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/
视角技术口径:路由 / 数据表 / 接口 / 权限使用视角:怎么点、填什么、会踩什么坑
来源功能文档.mdgen_docs.py 渲染手写 docs/guide/index.html
可见范围仅 admin(admin_only全员
发布python3 gen_docs.py + scp docs/index.htmlsh .deploy/sync_guide.sh

同步机制(防「界面改了、文档没改」)

docs/guide/check_sync.py 是手册的同步自检脚本,扫 7 个方案设计相关模板(schemes / scheme_form / scheme_detail / plan / bom / quote / drawing)里的真实按钮文案,与手册交叉比对,报三类问题:

类型 含义 是否阻断发布
[缺失]界面有、手册没写 —— 新人会找不到阻断(退出码 1)
[失效]手册写了、界面已经没了 —— 新人会对着空气点提示
[可能过期]模板 mtime 比手册新 —— 界面动过、手册没动提示
[断图]手册 <img> 引用的截图文件不存在阻断(退出码 1)

维护约定:

  1. 改了方案设计相关模板,必须跑 python3 docs/guide/check_sync.py,按报告改手册。
  2. 发布只用 sh .deploy/sync_guide.sh —— 它先跑自检,通过才上传、改属主、逐条验证线上 200。不要手动 scp 绕过。
  3. 新增页面必须把模板加进 check_sync.pySCAN_PAGES,否则该页面脱离监控;只有「取消 / 关闭 / 纯筛选」类才加进 IGNORE
  4. 界面变化大时重新截图,替换 docs/guide/shots/ 下同名文件。
  5. 技术口径改动走 功能文档.mdgen_docs.py/docs/,两套都要更新才算完成。

2. 权限体系

2.1 角色(5 种)

admin 管理员 · manager 经理 · designer 设计师 · sales 销售 · viewer 访客(只读)

2.2 权限装饰器

装饰器 允许角色 用途
@login_required任意已登录查看类页面
@staff_requiredmanager / 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公开登录 / 登出
/profilelogin个人资料:显示名、手机号、修改自身密码
/usersadmin员工列表(5 角色)
/users/<uid>/updateadmin编辑员工资料 / 角色
/users/<uid>/passwordadmin重置密码(前端二次确认弹窗)
/users/<uid>/toggleadmin启用 / 停用(前端二次确认,停用后无法登录)
/users/<uid>/deleteadmin删除员工

3.2 客户管理

路由 权限 功能
/clientslogin 查看 / 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_sideplans 调用处已加注释锁死。 - 其它保护:GIF 跳过压缩(PIL 只取首帧会丢动画);压缩后反而更大则用原图(不做负优化); 保留 EXIF 方向(ImageOps.exif_transpose),否则手机直出图压缩后会躺倒。 - 存量迁移.deploy/migrate_compress_products.pyDRY=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 加参数」零成本缩图,必须服务端压。
路由 权限 功能
/categorieslogin 查看 / admin 写设备分类(支持父级,树形):改名、改挂上级、合并转移、删除
/categories/delete/<cid>admin(POST仅删空分类(挂设备/子分类时拒绝并提示转移)
/categories/merge (新)admin合并分类:设备整体改挂目标分类 + 子分类提升 + 删除源分类(事务保护)
/productslogin设备列表,支持三重筛选:关键词 q(名称/品牌/型号)、分类 cat标签 tag 二次搜索(标签可点击跳转 ?tag=xxx
/products/formadmin新增 / 编辑设备
/products/delete/<pid>admin(POST删除设备
/products/importadmin链接采集导入(淘宝/京东等)
/products/import_templatelogin下载导入模板
/products/upload (新)admin设备图片即时上传(AJAX,返回 JSON {ok,url}),供粘贴 / 拖拽 / 选择三种入口复用
/drafts 及 save/list/deletelogin 查看 / admin 写(delete 为 POST采集草稿(product_drafts 待上架库存)
设备图片上传(三种入口,2026-09-03 新增) 1. 粘贴:在设备表单页面任意位置按 Ctrl / + V 粘贴截图即可。全局监听 paste 事件,仅在剪贴板含图片时接管preventDefault,粘贴纯文本不受任何影响。截图本身无文件名,前端按 MIME 自动补全(如 image/pngpaste-<时间戳>.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 智能面板场景(驱动公共首页)

路由 权限 功能
/sceneslogin 查看 / admin 增删场景列表(按 level L1–L4 分级)
/scenes/form /scenes/delete/<sid>admin(后者 POST编辑 / 删除场景
/api/scenes公开供公共首页读取 L1–L4 面板场景段落

scenes 表字段level(L1–L4)、namedescriptiondevicessort_orderstatus

公共首页「智能面板场景」段落由该接口数据覆盖内置文案;文案须保持米家视角

3.5 方案设计(核心)

路由 权限 功能
/schemelogin方案列表
/scheme/formstaff新建 / 编辑方案(名称、等级 L1–L4、面积、客户、设计费率、人工费率、备注)
/scheme/<sid>login方案详情:房间与设备分布、汇总金额(设备/设计/人工/合计)
/scheme/<sid>/room /room/delete/<rid>staff(后者 POST房间增 / 删
/scheme/<sid>/item /item/delete/<iid>staff(后者 POST设备增 / 删
/scheme/<sid>/planlogin 查看 / staff 写户型图(智能施工图)与设备点位,含控制图(控制关系可视化与编辑)
/scheme/<sid>/plan/uploadstaff上传户型图(支持 DXF/DWG)
/scheme/<sid>/plan/pointlight/move/deletestaff点位新增 / 灯光参数 / 移动 / 删除
/scheme/<sid>/plan/autofillstaff点位按房间设备自动填充(已优化为单次 JOIN 查询)
/scheme/<sid>/plan/linkstaff(POST建立控制关系(upsert;支持 mode_id>0 触发灯光模式)
/scheme/<sid>/plan/link/delete/<lid>staff(POST删除控制关系
/scheme/<sid>/mode/savestaff(POST灯光模式 upsert(mid 可空 = 新建),全量替换 items
/scheme/<sid>/mode/<mid>/deletestaff(POST删模式(级联清空 scheme_mode_items;引用此模式的 link 行保留但 mode_id 清零)
/scheme/<sid>/twinlogin 查看数字孪生(2.5D 等轴测 + 场景联动 + 光晕,独立页面,与 plan.html 不互改)
/scheme/<sid>/mode/datalogin读取方案全部模式 + items(前端预览/校验用)
/scheme/<sid>/sharestaff(POST保存分享设置 —— 每方案唯一链接,可设有效期 / 是否显示型号 / 勾选「重新生成」轮换 token
/scheme/<sid>/apply-templatestaff套用报价模板快速生成设备清单
/share/<token>公开客户只读预览页(根路径,不带 /app 前缀/app/share/<token> 作为别名同样可访问)
/scheme/delete/<sid>staff(POST删除方案

方案分享(客户只读预览 · 每方案唯一链接)

1. navigator.clipboard.writeText()(仅当 window.isSecureContext 为真时尝试);

2. 隐藏 textarea + document.execCommand('copy')(兼容 HTTP 非安全上下文);

3. 都失败则自动 Range 选中链接文本,提示「自动复制失败,已为你选中,请按 Ctrl/Cmd + C」。

成功时提示「已复制:<完整链接>」并把链接明文回显,方便肉眼核对。

分享页呈现(标题 + 完整报价,2026-09-05 完整化)

未绑客户时退回「{方案名}(等级)」,如「Rio.的智能方案(L2)」。后台预览与分享页共用同一函数

避免两处各写一遍导致显示漂移(教训来自 2026-09-05 跨页验证时的实际发现)。

+ og:description(如「110 ㎡ · L2 · 8 件设备 · 合计 ¥ 4747.13」),链接一眼能识别。

+ 报价日期 + 有效期 + 备注(含设备/设计/施工费用,不含硬装与税费,最终以合同为准)

+ 乙方署名(复用 doc_export._load_company(),个人 / 公司双措辞自动切换)。

取消勾选用作销售节奏(先让客户认可设计、暂不谈价)。

一次性脚本 /tmp/fix_share_price.py 刷回 1。改代码必须改存量数据,否则用户感知不到。

方案详情页「就地绑定客户」

但没告诉去哪儿绑。点「编辑方案」要跳另一页面再回来,体验太绕。

- onchange 自动 POST 到新路由 scheme_bind_client

- 无 JS 环境(<noscript>)显示「绑定」按钮兜底;

- 提交后回到详情页,顶部 flash「已绑定客户:xxx」。

- 只 UPDATE client_id + customer,不动其它字段(避免整行覆盖的教训);

- 同步客户表里的名称文本(与方案表单保存逻辑一致);

- 解绑时只清 client_id保留手填的客户名称文本避免数据丢失;

- 已登记 AUDIT_ENDPOINT_LABELS绑定/解绑方案客户),进审计。

把用户指引到刚刚做的下拉上。

添加设备交互(方案详情页)

方案列表页(/scheme)体验增强

- 主入口:📝 设计(蓝)+ 📋 复制(按该方案结构新建);

- 工具:户型图 / BOM / 报价 / 水电(链接)+ 分享POST 表单按钮,外观与相邻 chip 一致);

- 管理(仅 staff):编辑 + 删除(红色,二次确认后跳 /scheme/delete/<id>)。

方案详情页(/scheme/<id>)房间概念分离

- 🏠 物理房间造价统计范围,含「+ 房间」(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 过滤)。

3.6 项目管理

路由 权限 功能
/projectslogin项目列表(含阶段、客户、金额汇总)
/project/formstaff新建 / 编辑项目(编号、名称、客户、阶段、预算、合同额、已付、负责人、起止日期)
/project/<pid>login项目详情:阶段进度、关联方案、财务
/project/<pid>/stagestaff更新阶段完成状态
/project/<pid>/scheme/link /scheme/unlink/<sid>staff(后者 POST关联 / 解绑方案
/project/<pid>/financestaff财务(合同额 / 已付 / 回款)
/project/delete/<pid>staff(POST删除项目

数据表projects · project_stages(阶段进度)· project_schemes(项目-方案关联)。

3.7 报价与模板

路由 权限 功能
/quote-templates /quote-templates/delete/<tid>admin报价模板库管理(预设标准方案,一键套用)
/scheme/<sid>/apply-templatestaff在方案设计中套用模板
/quotelogin报价页(基于方案设备 + 费率生成)
/quote/export/<fmt> /scheme/<sid>/export/<fmt> /contract/export/<fmt> /bom/exportlogin导出(报价单 / 方案 / 合同 / BOM,多格式)

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/exportloginBOM(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 两条渲染链共用,杜绝两个分支各抄一遍造成的内容走样。

<方案名>_<类型>_<方案编号>.<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 对象 keyscheme_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 列后正常)。

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>/archivestaff(POST)批量归档:一次性生成 6 个产物(3 类 × PDF/Word)全部落 COS,不返回文件
/scheme/<sid>/file/<fid>/downloadlogin下载归档文件(私有对象走服务端代理)
/scheme/<sid>/file/<fid>/deletestaff(POST)删除归档:先删 COS 对象再删索引,COS 失败则不删索引(不留孤儿记录)

关键设计

坑(已修):COS SDK 的 StreamBody.read(chunk_size) 只返回单个分块(默认 1024 字节),用它实现代理下载会拿到被截断的文件。必须用 b''.join(body.get_stream(chunk_size=)) 拼接——它走 iter_content() 且能正确处理 Content-Encoding

3.8 BOM(物料清单)

路由 权限 功能
/bomloginBOM 列表(按方案汇总设备、数量、金额)
/bom/exportloginBOM 导出

3.9 智能施工图

1. 水电预留:各设备强电/弱电/网络预留汇总 + 按房间交底清单(接入方式、施工备注)

2. 网络布线:AP 覆盖、网络角色(net_role)、点位布点

3. 开关面板:面板类型(panel_type)、安装高度(panel_height

4. 木作灯光:木作预留(woodwork)、灯光点位

3.9.1 控制图(点位 + 控制关系可视化)✅ 已上线

- 表 scheme_point_linksid, drawing_id, switch_point_id, ctrl_point_id, link_type(physical/virtual), key_no(1-3), action(single/double/long), mode_id, created_atUNIQUE 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, ''} —— 标识点位类型。

1. 面板类型 ∈ {零火, 单火, KNX, 情景} → switch

2. 品类/型号含「灯 / 光源」→ light

3. 含「窗帘 / 开合帘 / 梦幻帘 / 香格里拉」→ curtain

4. 含「传感器 / 探测 / 人体 / 存在 / 门磁 / 水浸 / 烟感 / 温湿度」→ sensor

5. 含「无线AP / 面板AP / 吸顶AP / AP面板」→ ap

类型 图形 主色
开关 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)。

- 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 让百分比数字不抖。

- JSinitZoom() 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 持平。

- 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」

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):之前的 renderGlowsdocument.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. 尺寸自愈renderLinkscw||ch===0 时隔帧重试(最多 40 帧),并新增 ResizeObserver 监听 canvas 尺寸变化——户型图加载慢、侧栏折叠过渡中的「整批不画线」彻底消失。

6. 后端 lastrowid 修复:两处 INSERT ... ON DUPLICATE KEY UPDATEid=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 形干净利落,无相互穿插)

- 不再响应 wheel/双指缩放:「不要双指放大 只能单击左上角的放大缩小」。canvas.wheelpreventDefault() 阻止页面滚动 + 阻止 trackpad pinch-to-zoom 误触,不再调 setScale;同时吃掉 Safari 的 gesturestart/change/end#canvastouch-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-wraptransition: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% + 整体平移,连线/徽标都跟动)

- 真因:工具栏 <div id="zoomToolbar">#canvas 内部,点 + - ⟲ 按钮时事件冒泡到 #canvas 的 click handler;该 handler 只排除了 .pt#glowToggle没排除按钮/工具栏——落到「点击画布空白处 → 放置已选设备」逻辑,因没选设备 alert「请先在右侧选择要放置的设备」。

- 修 1canvas 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) 弹窗 [] ✓;真点空白 → 弹「请先在右侧选择要放置的设备」 ✓(该弹的还弹)。

- 两个 bug 同根:canvas 有 min-height: 360px(zoom 改造时为工具栏兜底加的),与 img 高度解耦——当图自然高度 < 360 时,canvas 高度 = 360 但 img 只占上面 240px,下方 120px 空白。.pttop: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: 360pxaspect-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 持平

- 两个 bug 同根:canvas 有 min-height: 360px(zoom 改造时为工具栏兜底加的),与 img 高度解耦——当图自然高度 < 360 时,canvas 高度 = 360 但 img 只占上面 240px,下方 120px 空白。.pttop: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: 360pxaspect-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 错。

- 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 }};

1. 进入页面默认「浏览模式」:可拖拽点位、查看连线、点击连线聚焦。

2. 切到「控制模式」:先点一个开关点位(必为 switch),右侧控制关系面板出现「已选开关: <name>」+ 三个下拉「键号(1-3) / 动作(单击/双击/长按) / 线型(物理/虚拟)」;

3. 再点任意非开关点位(灯 / 窗帘 / 传感器)→ 自动调 POST /plan/link 建立关系(upsert 同 UNIQUE 键);

4. 已建关系列在 #linkList,右侧「删」按钮调 POST /plan/link/delete/<lid>

- 两端必须同图同方案(JOIN scheme_drawings 验证);起点必须是 switch,终点不能是 switch(暂不支持开关控开关);

- 不通过校验返回 {ok:false, msg:...} 不写库。

- 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」)/searchpname/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)点选 <lipreventDefault() 避免触发 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」按钮 → 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.pyapp.test_client 真打路由,端到端 ALL PASS)。

3.9.2 灯光模式(场景联动)+ 按键动作表 ✅ 已上线

- 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_linksctrl_point_id=0mode_id>0 表示"这个开关的这个键/动作触发这个模式",强制为 virtual 线

- 按房间分组列出方案内所有 light 类点位(被引用的 sensor / curtain 不进模式);

- 每行 checkbox + 开/关 select + 亮度 number(仅开 + 灯才允许设亮度,关态亮度自动清 NULL);

- 名称 1–60 字;保存走全量替换(先 DELETEexecutemany INSERT),保持简单。

- 右侧模式卡点 chip 进入预览态 → 图上 light 点位加 .preview-on 金色实心+光晕 / .preview-off 半透+光晕关;

- 提示行「正在预览模式「xxx」—— n 灯开,m 灯关。再点该模式或画布空白处退出。」;

- 切换时只是 DOM 渲染变化,不落库,浏览模式不受影响。

- 列:键位 | 动作 | 目标 | 类型 | 删除

- 排序:先按键号升序,再按动作(单击→双击→长按)顺序;

- 模式目标用 ⚡金底文字(#8a5b12)特殊标记;

- 聚焦面板上某条连线时(focusLink)也能正确显示。

- 普通键:黑底白字 "键N·动作";

- 模式触发:金底白字 "⚡键N·动作";

- 模式触发的线在图上画成自环linkPathisMode 分支:开关点甩出 30px 再绕回),直观表达"这个键触发了一个场景";

- 浏览模式(非控制模式)下徽标隐去保持图面干净。

- 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'

3.9.3 数字孪生(2.5D 等轴测可视化)✅ 已上线

- 全亮 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 视为主灯(更接近真实设计师按点位走线的习惯);房间未匹配 → 退化兜底(仅对按房间场景生效,不污染全关/全亮)。

3.10 公共首页(静态页)

文件public/index.html → 部署到 /www/wwwroot/quyuzhineng/index.html(Nginx 直出,非 Flask,无构建工具)。

配图public/img/*.webp(部署到站点根 /img/),共 5 张、344KB。

定位:L1 到 L4 分级选型工具页(决策辅助,不是纯营销落地页)。核心任务是帮用户在四个等级里选出适合的那个,再导向选配问卷。

区块 布局族 说明
导航单行 sticky64px,分级体验 / 智能选配 / 管理后台
Hero非对称 split(文 \图)单一主 CTA「看看四级的差别」锚点下跳
等级选择器4 列等宽卡片静态写入 HTML,每张含序号 / 名称 / 定位 / 预算量级 / 适合人群
等级详情2 列(能力 \设备)随等级切换,JS 渲染
经典场景图 \文 split场景图随等级切换(5 张实拍)
面板场景auto-fill 卡片网格内容可由后台 /app/api/scenes 覆盖
四级对比折叠表格(8 行)解决来回点击的记忆负担
落地提醒3 列文本块网络地基 / 品牌上限 / 面板融合时机
出口 CTA全宽「开始智能选配」→ /quiz.html

交互与状态

设计约束(改版时定下的规矩,别回退)

回滚点index.html.bak(改版前的旧版,cp index.html.bak index.html 即可回退)。

跨页设计语言统一(2026-09-04 两轮同步):

真无障碍缺陷修复(验证脚本先抓到再去改):

  1. 键盘用户走不完向导 — 选项原本是 <div> + onclick,tabindex=null,单选步没「下一步」。修法:tabindex="0" + role="radio|checkbox" + aria-checked + 容器 role="radiogroup" + Enter/Space/方向键 + focusStep() 换步后送回焦点。
  2. 表单边界 1.24:1(WCAG 1.4.11 需 ≥ 3:1)—— 加 --line-strong + :focus-visible 焦点环 + 输入聚焦软晕。
  3. :active 零反馈 —— .btn:active{translateY(1px)} / .opt:active{scale(.985)}
  4. .opt{transition:.2s} 通配全属性 —— outline-color 都被动,焦点环 200ms 才「渐显」。改成显式列属性 + 按压位移单独 .12s

灯泡 SVG + 色温映射修复:💡 emoji 改内联 SVG + currentColorhsl(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.webpaspect-ratio:16/9 占位,CLS 仍 0);×按需· 数量按需;窗帘类目命名统一;已联网版已连后台;装饰 emoji(🚪🛋️🏠🎬)删,✅ 换 SVG 勾。

验证脚本.deploy/_verify_quiz.py(Playwright 58 项全过)+ .deploy/_shots_quiz.py(截图 + 性能)。可传 URL 跑任意环境。

第二轮(2026-09-04 同日,扩到四个页面)

第三轮(2026-09-04 同日,扩到第五个页面——后台 /app/

后台也用同一套令牌(详见 app/templates/base.html 与 §5 最新一条变更记录)。templates/base.html 是所有 29 个后台子模板的父模板,改一行全局生效(因此模板改动必须 systemctl restartkill -HUP 不重编译 Jinja2)。重要约定:

3.11 仪表盘

3.12 选配向导(公共首页引导选设备)✅ 已上线

- ① 网络基础(4 档:完全不关心 / 仅刷视频 / 游戏零卡顿 / 电脑有线打游戏)→ 推荐路由器 / Mesh / AC+AP 方案

- ② 控制中枢(需要?→ 带屏中控 / 无屏网关 / 都要)

- ③ 照明控制(交互演示:三种搭配单选——智能灯+普通开关 / 普通灯+智能开关 / 智能灯+智能开关(无线模式)⭐推荐;含灯泡可视状态 + 墙开关 + 手机面板色温/亮度;末尾灯组玩法:客厅筒灯/灯带/主灯 + 全屋手机面板 + 场景按钮 + 动画)

- ④ 窗帘门窗(需要?→ 客厅 / 主卧 / 儿童房 / 书房 / 开窗器,可多选)

- ⑤ 传感感知(需要?→ 毫米波人在 / 门窗磁 / 温湿度光照 / 空气,可多选)

- ⑥ 安防(门锁 / 摄像头 / 燃气水浸烟感 / 红外幕帘,多选)

- ⑦ 暖通 HVAC(中央空调 / 风管机 / 普通壁挂空调 / 地暖 / 新风 / 面板融合,多选)

- ⑧ 影音(需要?→ 背景音乐 / 投影电视联动 / 智能音箱,多选)

- ⑨ 清洁(扫地机 需要/不需要)

- ⑩ 目标场景(L1–L4 场景多选)

- ⑪ 客户信息(称呼 / 电话 / 面积 / 类型 / 补充)

- 复制方案名:只复制「方案编号 + 方案名称」(如 QLZ-20260818-SU8U zzz的110㎡ 平层 智能家居方案(L3 全屋智能)),方便贴到客户档案/微信备注;不再复制整段方案文本。

- 打开后台方案:跳到 /app/scheme/<id>(后台方案详情页),按钮旁标注「仅管理员可见」并附「管理后台」链接,引导设计师登录后台调整。

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.noteproduct_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)。

- 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 取消,移除 onclicknextBtn.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/draftsadmin
场景方案场景管理 /app/scenes · 方案设计 /app/scheme设备库类 admin,方案全员
项目交付客户管理 /app/clients · 项目管理 /app/projects · 智能报价 /app/quote · 自动 BOM /app/bom · 智能施工图 /app/drawinglogin
员工员工管理 /app/users · 系统设置 /app/settings · 操作审计 /app/auditadmin
帮助使用手册 /guide/ · 功能文档 /docs/(admin)手册全员,文档 admin

- 功能模块{% 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 只有「使用手册」一张。

- 入场动画:docanimation-delay 接在最后一个 mod 之后,两块依次淡入。

3.15 操作审计(谁在什么时候做了什么)✅ 已上线

入口/app/audit仅 admin 可见可用(路由 @admin_required,侧栏 admin_only)。回答的问题是「任选一个员工、任选一个时间点,他在系统里做了哪些操作」。

路由 权限 功能
/app/auditadmin审计查询(筛选 + 分页 + 双维度汇总)
/app/audit/exportadmin按当前筛选导出 CSV(BOM 头,上限 5 万行)
/app/audit/purgeadmin(POST)按天数清理历史记录(清理动作本身会留痕)

采集方式:全局钩子,业务代码零侵入

app.py@app.after_request 统一埋点,audit_record() 写库。三个要点:

  1. 写操作全记(POST / PUT / DELETE),读操作只记敏感动作 —— 白名单 AUDIT_LOG_GET_ENDPOINTS = 登出 / 三类导出 / BOM 导出 / 归档文件下载 / 导入模板下载 / 客户分享页访问 / 审计导出。普通页面浏览不记,否则日志会被翻页刷爆。审计页自身(audit)也不记。
  2. 登录失败单独显式记录:此时还没有会话,get_current_user() 拿不到人,只能在 login() 里显式调 audit_record('login', ..., note='密码错误'/'账号不存在'/'账号已停用')。显式调用会置 g._audit_done,after_request 不再补一条,避免同一次操作记两遍
  3. 登出必须在 session.clear() 之前记录,否则日志里登出的人永远是「(未登录)」。

记录内容(表 audit_logs):时间 / 账号 / 姓名 / 角色(当时快照)/ 操作分类 / 动作中文名 / 请求方法 / 路径 / 对象类型+ID+名称 / 提交内容 / IP / 浏览器 / HTTP 状态码。

查询界面

已知边界:审计从 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 EnvironmentQLZ_COS_SECRET_ID / QLZ_COS_SECRET_KEY / QLZ_COS_BUCKET / QLZ_COS_REGION),不写进仓库;未配置时页面标红提示。

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/*.png9 张真实后台界面截图(01-scheme-list09-quote
docs/guide/check_sync.py同步自检脚本,见 §0.1
.deploy/sync_guide.sh一键发布:自检 → scp → chown → 逐条验证 200
.deploy/docs_static.confNginx 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. 数据模型(表清单)

关键字段 关联模块
usersusername, password_hash, role, display_name, phone, status3.1
clientsname, phone, address, tier, budget, source, status3.2
categoriesname, parent_id, sort3.3
productscategory_id, name, brand, model, price, protocol, access_mode, tags, net_role, ap_cover, panel_type, panel_height, woodwork, image_url, status3.3 / 3.9
product_draftssource_url, title, brand, model, price, status, product_id3.3
sceneslevel, name, description, devices, sort_order, status3.4
schemescode, name, level, area_m2, client_id, customer, design_rate, labor_rate, status3.5 / 3.7 / 3.14
scheme_roomsscheme_id, name, sort_order3.5
scheme_itemsscheme_id, room_id, product_id, qty, note3.5
scheme_drawingsscheme_id, name, image_url3.5
scheme_pointsdrawing_id, scheme_item_id, room_id, product_id, label, x_pct, y_pct, point_kind(switch/light/curtain/sensor/ap/空), light_type, watt, light_dir3.5 / 3.9.1
scheme_point_linksdrawing_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_modesscheme_id, name, sort_order, created_at(UNIQUE(scheme_id, name)3.9.2
scheme_mode_itemsmode_id, point_id, state(0/1), brightness(1-100 或 NULL)(UNIQUE(mode_id, point_id)3.9.2
scheme_sharesscheme_id(UNIQUE,一方案一条), token, expire_at, show_model3.5
scheme_filesscheme_id, kind, fmt, fname, cos_key, acl, size, created_by(UNIQUE(scheme_id,kind,fmt)3.7.3
quote_templatesname, data(JSON)3.7
projectscode, name, client_id, stage, budget, contract_amount, paid_amount, owner, start_date, expected_date3.6
project_stagesproject_id, stage_key, stage_name, done, done_date3.6
project_schemesproject_id, scheme_id3.6
audit_logsts, 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_settingsid(固定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_acl3.7.2 / 3.7.3 / 3.14

5. 变更记录

倒序记录。每次功能修改在此追加,并同步更新上方对应模块章节。

2026-09-09 · 六个方案子页面统一导航条(_scheme_nav.html

跳转入口。进了「户型图」切不到「数字孪生」;进了「智能报价」只有一个「返回设计」,要看别的

得先退回方案详情再重新进。

设计 · 户型图 · 数字孪生 · BOM · 报价 · 施工图 6 个入口,6 个页面 {% from ... import %}

共用;各页在导航条后面追加自己的专属按钮(导出方案册/合同、导出 SVG/米家配置清单、导出 CSV、

导出Word/PDF、编辑方案、使用手册)。

不占用 .btn 实心——实心留给「导出 SVG / 导出 CSV」这类本页主操作。此前用实心表示当前页,

在户型图页会同时出现「户型图」+「导出 SVG」两个实心,分不清哪个是导航哪个是操作。

链接带方案 id/专属按钮保留/按钮无裁剪 + 3 条真实跳转:户型图→孪生、报价→户型图、施工图→BOM)。

孪生页 24 项断言仍全绿。

2026-09-09 · 修「顶栏按钮在窄窗口被裁到屏幕外」(全站,非孪生页独有)

2026-09-09 · 数字孪生入口 + 场景按房间差异化

2026-09-09 · 修「拖动点位与鼠标位置不符」+「外接屏点位漂移」(同根)

验证(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 · 修「拖动点位与鼠标位置不符」+「外接屏点位漂移」(同根)

验证(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 完不误触

验证(Playwright):点 + → 1.2 弹窗 [] ✓;pan 完 → 弹窗 [] ✓;真点空白 → 该弹还弹 ✓。

2026-09-09 · 画布缩放改成「只工具栏」+ 空白处拖动整体平移

验证(Playwright,生产方案 2):滚轮 5 次 scale 不变 ✓;工具栏 + → 1.2 ✓;空白处拖 100,100 → pan=(100,100) grabbing ✓;拖点位不抢 ✓;⟲ → 全归零 ✓;JS 0 错;30 路由 0 失败;dark 4 / light 8 持平。

2026-09-09 · 控制图控制关系「即建即见」+ 路由 L 形化 + 光晕崩溃真因修复

验证(生产方案 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「命名没按规范、名字跟颜色重合看不清、名字之间重叠」。

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 加参数」零成本方案不可用,

改为服务端压缩 + 存量迁移

改动

  1. cos_client.py 新增 _maybe_compress():等比缩到 max_side 内 + WebP(q82),

ImageOps.exif_transpose 保 EXIF 方向;GIF 跳过;压缩后更大则用原图。

upload_image(..., max_side=None) 默认不压缩

  1. app.py 产品图两处上传(913 product_upload / 992 product_save)传 max_side=800

户型图(2639 plan_upload)保持不压缩并加注释锁死。

  1. 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 · 方案详情页「就地绑定客户」(消除绑客户绕一圈)

只 UPDATE client_id + customer,不动其它字段。

解绑只清 client_id,保留手填的客户名称文本。

已登记 AUDIT_ENDPOINT_LABELS 审计标签。

<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)

未绑客户退回「{方案名}(等级)」。分享路由与后台详情页预览共用这一函数

- 摘要条补 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。

引导去「方案设计 → 编辑方案」绑定客户。

存量数据修复

排查发现是上一轮「默认不勾选」版期间创建的分享记录 show_price=0 没刷回 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 新增)

返回 设备小计, 设计费, 施工费, 合计)。

改走该入口,两处取设备明细的 SQL 也统一带上 BILLABLE_ROOM_JOIN

kind<>'meta' 过滤。一旦有设备挂到 meta 房间,客户拿到的报价单会比后台多钱。

线上当时 1 个方案 8 条设备全在实体房间,差异未暴露。

验证:三处渲染出的合计逐分一致(均为 ¥4747.13);后台 30 路由 0 失败;

6 种导出组合(报价单 / 合同 / 方案册 × docx / pdf)全部正常。

方案分享页价格开关(§3.5)

控制分享页是否展示设备单价、小计与费用汇总。

《前端设计文档.md》§1.1 的「零价格焦虑」原则只管潜客选型阶段(首页 / 选配向导),不套用到这里。

「清单只列出设备与数量。具体报价由设计师与你沟通后单独给出。」(不留空)

2026-09-04 · 后台 /app/ 侧栏 + 概览改造 + 子模板一致性收口

侧栏与概览

子模板一致性收口

移动端 480 优化

新工具链

2026-09-04 · 功能文档 /docs/ 与新人手册 /guide/ 跨页令牌对齐 + 深色模式

公共首页 / 与选配向导 /quiz.html 已统一到同一套设计令牌,但 /docs//guide/ 长期停留在各自旧配色——/docs/ 用的还是 #2f6df6(另一个蓝),且四个页面没有一个全局自定义焦点环(都靠浏览器默认 1px / auto / rgb(0,95,204),在浅色卡片上几乎看不见)。本轮把四个页面纳入同一套令牌,并加机器可校验的跨页一致性验证。

一、/docs/(gen_docs.py)改动

二、/guide/(docs/guide/index.html)改动

三、/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 项)
Dreduced-motion 把过渡压到 <= .01s(4 项)
E正文对比度 ≥ 4.5:1 + 链接对比度 ≥ 4.5:1(自动排除 nav/header/footer)
F真键盘 Tab 出现自定义焦点环(实线 ≥ 2px + 品牌色,不是浏览器默认)
G375 窄屏无横向滚动(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)。

首轮自查修掉的真实问题(验证脚本先发现再去改代码):

踩坑记录:同一条消息里对同一文件发两次 Edit 会丢更新(典型的 read-modify-write 竞态,后写的覆盖先写的)。需要多次编辑同一文件时,必须串行或合并成一次 Python 脚本(原子)。

2026-09-04 · 选配向导 /quiz.html 深度审查 + 无障碍与交互状态补齐

design-taste-frontend 规范对 /quiz.html 做完整深度审查后的第二轮修复。仍只动 public/quiz.html(74,269 字节)与验证脚本,业务逻辑零改动。

一、两个真实无障碍缺陷(不是风格问题,是功能缺失)

二、交互状态补齐(4.5 Interactive UI States)

三、形状与装饰规范

四、一处真 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.webpaspect-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 个请求。

首轮自查修掉的真实问题

2026-09-04 · 选配向导 /quiz.html 设计与首页对齐 + 体验断点修复

紧跟首页改版(同一轮 UX 优化)。本轮只动 public/quiz.html(59KB)与两个验证脚本,业务逻辑零改动。

- 照明搭配步的「下一步」在被禁用时加 .next-hint 提示(「先在上面的搭配里勾选…」),勾选后自动隐藏。

- 联系表单必填未填时标红,输入即清除(之前要等下次点保存才消失)。

验证.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)。

首轮自查修掉的真实问题(验证脚本先发现再去改):

2026-09-04 · 公共首页 / 改版:从「营销页」重做为「分级选型工具」

一、流程重构(本次重点)

二、视觉重做(遵循 design-taste-frontend 规范)

三、内容修正

四、性能

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 步走完能独立产出可报价方案。

二、文档同步机制(§0.1) —— 把「功能更新要同步文档」从口头约定变成机器可校验的门禁。

三、首轮自检发现并修掉的手册错误(证明机制有效)

四、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 的"一开关一灯"扩展为"一键触发一组灯的目标态",并把开关的键位动作做成一张正式表。

二、几点设计决定

2026-09-03 · 控制图 P1 上线(开关-灯-窗帘-情景控制关系可视化)+ 设置页分段控件修复

一、控制图(§3.9.1) —— 给方案详情页的户型图点位层加上「控制关系」可视化与编辑。

二、设置页分段控件修复(§3.14)

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)

二、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">postschemes.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 · 分享唯一链接 + 完整地址显示 + 复制修复 + 文件名规范化 + 归档卡下移

用户诉求:*「方案分享要保留唯一链接 并且能在界面显示完整链接地址,复制按钮复制的并非地址,生成的文档名字要有规划 最好跟项目名有关,交付物归档(对象存储)放在设计页面最下方」*。五项全部实现并上线验证。

一、分享链接唯一化(一个方案 = 一条链接)

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

二、界面显示完整绝对地址

新增 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.clipboardundefined,初版 navigator.clipboard.writeText(...) 直接抛 TypeError 被静默吞掉 → 表现就是「点了没反应」。改为三级降级:

  1. window.isSecureContext 为真时用 navigator.clipboard.writeText()
  2. 否则用隐藏 textarea + document.execCommand('copy')(非安全上下文下仍可用);
  3. 都失败则自动 Range 选中链接文本,提示手动 Ctrl/Cmd + C

成功提示改为「已复制:<完整链接>」回显明文,便于肉眼核对。

四、交付物文件名规范化(与项目名挂钩)

新增 doc_export._safe_name() / doc_export.doc_fname(),规则 <方案名>_<类型>_<方案编号>.<ext>

实测 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.oklocation.reload(),失败弹提示(不再静默假成功)。

二、点位按方案数量限流(服务端 + 前端双保险)

三、删除点位前留档到审计日志

plan_point_deleteDELETE 之前 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-clampmax-width:220px + text-overflow:ellipsis),原文进 title 悬浮全文;超过 24 字追加「展开 / 收起」链接(toggleNote().open 换行显示)。

五、按钮不再被压成竖排

base.html .btnwhite-space:nowrap;flex:0 0 auto.topbar .actionsflex-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/报价、但能自由放置用于设计控制关系和灯光模式」。

用户原话:*「客户不会在我这里买灯,但要设计开关与灯的智能化逻辑,所以要创建很多虚拟的灯;但不想在设备界面添加很多很多灯,因为没有实际成本,也不用做在设备报价表里面」*

实现

BOM / 报价自动不进bom.html / quote.htmlscheme_items 聚合(设备项),虚拟点位 scheme_item_id=0 不会进入聚合。控制关系scheme_point_links)和灯光模式_load_modespoint_kind IN ('light','switch'))都按 point id,与 item_id 无关,虚拟灯自动支持。

自建自清验证 .deploy/verify_virtual.py(17 项全 PASS,含 4b 临时改 qty 测服务端校验):

回归:30 路由 0 失败;深色对比度 4 处 / 浅色 8 处(与上轮持平,无回退)。

2026-09-08 · 控制图灯线重叠 → 阶梯分散 + 控制关系串联分组

用户诉求:*「1. 单击命名的位置有点重叠,看看怎么放合理 2. 灯线有点重叠 看不清 不容易施工 3. 灯可能有串联的情况,比如开5的键3单击同时控制灯3灯4」*

实现

- lane 模型:每条线出发先水平/垂直错开一条小「车道」(lane = fan × 9pxfan = idx − (count−1)/2),再走各自的横干或竖干(拐点 t ∈ [0.2, 0.8] 沿主方向分散),最后入户。单条线(count=1lane=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):中点徽标 / 气泡跟线走(否则分散后标签会飘在线外)。

- renderLinkslk.sw 预统计 fanCount / fanIdx,逐条算 slot 传入;模式自环 (isMode) 不参与。

JS 语法自检:抽取 <script> 块 → Jinja {{ }} 替 0、{% %}/**/node --check;本轮 2 个块 0 错误。

关键踩坑(值得记)

生产数据验证(方案 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 / 个(串 / 联)),目标名被截成「过道...」。

实现

布局顺序(上到下):

  1. 户型图与设备点位(含 canvas,左 1fr / 右 300px 控制面板含「标注设备」+ 图例)
  2. 控制关系(控制图)(独立卡片,跨整行)
  3. 灯光模式(场景联动)(独立卡片,跨整行)
  4. 已标注点位 + 点位自检(独立卡片,跨整行)

回归:30 路由 0 失败;深色对比度 dark 4 / light 8(与上轮持平)。

2026-09-08 · 「选择方案内设备」下拉改成可搜索 combobox

用户诉求:*「这个选择设备最好能下拉选择+输入搜索」* —— 原 <select id="deviceSel"> 是普通下拉,方案设备一多(10+ 设备常见)就要滚动翻找,对「标注点位」这种低频定位操作效率太低。

实现

- 聚焦展开全量选项;已选中态下再次聚焦 → 自动定位到已选项 + 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 + 占位文案「方案内设备均已标注完毕(或暂无设备)」

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):

回归: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):

回归: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

<script>

(function(){

try {

if (localStorage.getItem('sideCollapsed') === '1') {

document.documentElement.classList.add('side-collapsed', 'side-boot');

}

} catch(e){}

})();

</script>

```

回归(Playwright 探针注入 DOMContentLoaded 时刻):

未做(可选后续):添加点位本身仍是整页 reload(后端 plan_point_add 是 form POST → redirect)。要彻底消除白屏需:后端加 JSON 分支返回新点位数据 + 前端插入 .pt DOM + 重绘连线 + 重建「已标注点位」表格行 + 更新设备下拉剩余数量。改动涉及前后端多处,风险中等,未在本轮做。

2026-09-09 · 侧栏支持整体折叠(桌面端)

用户反馈:*「想要左侧整体可折叠」* —— 左侧 240px 导航栏在 1500px 以下分辨率挤占主区,户型图 / 报表类页面需要更大的工作空间。

实现app/templates/base.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) 反向。

- .sidetransition:width .2s, flex-basis .2s 平滑过渡。

- .side.collapsedwidth:64px;flex-basis:64px.lbl{display:none} 全隐藏文字,.nav a 居中、.tag 隐藏、.side-section 居中。

- .side-toggle 32×32 圆角按钮,hover 反馈;折叠态 margin:8px auto 居中。

- 启动时读 localStorage.getItem('sideCollapsed') === '1' → 应用 .collapsed

- 点击 #sideToggletoggle('collapsed') + localStorage.setItem('sideCollapsed', '1'/'0')

- try/catch 包住 localStorage(隐私模式下可能抛错)

回归:5 项 Playwright 全过(初始 240px / 折叠后 64px / 展开回 240px / localStorage='1' / 刷新后仍 64px);30 路由 0 失败;dark 4 / light 8 持平。

2026-09-03 · 分享 405 修复 + 交付物归档到对象存储(COS)

用户诉求:*「方案设计 分享报错 Method Not Allowed」*、*「希望生成的方案可以保存到对象存储中特定的目录中」*。

一、分享 405(线上 Bug,已复现并修复)

根因是两个独立缺陷叠加,都已修复:

  1. 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 的视觉),并加二次确认。
  2. 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(新功能)

验证:6 个归档文件下载字节数与直出产物完全一致(cmp 通过,md5 相同);PDF 用 pymupdf 打开确认 5/1/2 页、标题为「全屋智能家居 服务协议」;COS 桶 list_objects 确认 6 个对象路径正确;删除测试后 COS 对象数 6→5,重新批量归档恢复;全站 9 个关键页面 + 导出路由全部 200。

2026-09-03 · 合同改「个人服务协议」+ 条款去专业化 + 分享链接修复

用户诉求:*「合同信息不用这么专业,我这边不是公司而是个人;分享的问题直接修复掉。」*

- 个人:全屋智能家居服务协议 / 乙方(服务方)/ 服务方姓名 / 乙方(签字)/ 「经双方签字后生效」/ 文件名后缀 协议

- 公司:全屋智能家居服务合同 / 乙方(施工方)/ 公司全称 / 乙方(签字 / 盖章)/ 「经双方签字(盖章)后生效」/ 文件名后缀 合同

- 个人模式下身份证号、联系地址留空则整行不出现在协议上_contract_info()kind 与值双重判断),避免个人主体被迫披露隐私信息。

```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 代码,已生成的分享链接全部继续有效。

2026-09-03 · 最终交付物重做:合同 / 报价单 / 方案册(对齐「松下智能」合同版式)

- 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.drawParaUnboundLocalError: 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%% 双百分号(字符串未走 % 格式化却写了转义)→ 改回单 %

2026-09-03 · 分类管理:改名 / 合并转移 / 删除保护

- app.py

- 新增 _descendant_ids(conn, cid) —— 迭代式递归取子孙 id 集合,供环检测用。

- categories() POST 分支:加同级重名校验name + parent_id 唯一性,因表无唯一约束只能应用层拦)、环检测(上级不得是自己或子孙)、操作结果 flash 回执(此前无论新增还是修改都静默跳转,用户无从得知是否生效)。

- categories() GET 分支:SQL 改为子查询带出 product_countsub_count

- category_delete():查设备数与子分类数,非空则 flash 拒绝并指引「先用合并到…」,空分类才删。

- 新增 category_merge()UPDATE products SET category_id=dst WHERE category_id=srcUPDATE categories SET parent_id=dst WHERE parent_id=srcDELETE 源分类,三段包在 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 四个函数),编辑时上级下拉禁用自身项。

2026-09-03 · 设备图片支持粘贴 / 拖拽上传

- 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> 保留但隐藏):三种入口(全局粘贴 / 拖拽 / 点击选择)+ 即时预览 + 「移除图片」+ 上传中/成功/失败状态。

- 粘贴实现要点:监听 documentpaste,遍历 clipboardData.itemskind==='file' && typeimage/ 开头的项,getAsFile() 取 Blob 后才 preventDefault()——因此纯文本粘贴完全不受影响。无文件名时按 MIME 补扩展名。

- base.html 新增 {% block head %}{% endblock %}(置于 </title> 后、主 <style> 前),供子模板注入页面级 CSS;对既有页面无影响(空 block)。

2026-08-19 · 侧栏菜单分级(物料设备 / 场景方案 / 项目交付 / 员工)

- 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 / 顶部分隔线(首个分组免)。

2026-08-18 · 在线功能展示页 /docs 上线

2026-08-18 · 修复方案列表「设备项」显示 <built-in method items of dict object>

2026-08-18 · 通信协议改为多选(复选框 + 手输)✅

2026-08-18 · 功能文档建立(基线)

2026-08-18 · 设备库标签 + 协议可选

2026-08-18 · 方案设计:添加设备置顶 + 类型/标签过滤

2026-08-18 · 选配向导 · 进度条可点击跳转 + 底部按钮固定吸底 + 联网徽标

2026-08-19 · 选配向导 10 处文案/流程优化(contact 直接落库 + 结果页去后台入口)

2026-08-18 · 选配向导 · 「我要灯组」依赖无线模式 + 排版修复 + 去除 Demo 角标

2026-08-18 · 选配向导 · 照明控制交互演示 ✅ 已上线

「③ 照明控制 · 形式」步骤改为可交互卡片,每张卡片包含:

- 智能灯 + 普通开关 — 墙关=断电→灯离线,手机全失控(不推荐)。

- 普通灯 + 智能开关 — 手机可开关但无法调光调色(普通灯物理上只能单色)。

- 智能灯 + 智能开关(无线模式)⭐推荐 — 墙开关=无线信号不切电源,灯始终在线;色温/亮度随时可调。

2026-08-18 · 选配向导 · 灯组交互演示 ✅ 已上线

本步骤末尾灯组精髓玩法重做:

2026-08-18 · 选配向导 · 正式上线对接后端 ✅ 已上线

本步骤末尾灯组精髓玩法重做:

历次重要变更(归纳)

2026-08-18 · 公共首页加 CTA 入口卡片(引导至选配向导)

2026-08-18 · 选配向导 · 复制方案名 + 后台调整提示

2026-08-18 · 选配向导 · 后端 scheme_rooms 与选配向导 1:1 对齐

2026-08-18 · 方案设计列表页整体优化(对齐 + 摘要 + 筛选)

1. 操作列三段式:主入口(📝设计 + 📋复制)|工具(户型图/BOM/报价/水电/分享 紧凑 chip)|管理(仅 staff:编辑 + 删除二次确认)。每段可换行、不挤压。

2. 顶部 6 张摘要卡:方案总数 / 启用中 / 停用·草稿 / 总房间 / 总设备项 / 已绑定客户数(前端实时统计)。

3. 筛选工具条:名称/客户/编号实时搜索 + 状态分页签 + 等级分页签(L1–L4)+ 排序(最近更新/编号/面积/名称)+ 「显示 N / 共 M 个方案」计数。

4. 列表项展示方案编号(monospace 小字)+ 方案名称行内可点击进详情。

2026-08-18 · 方案详情页 · 房间概念分离(room / system / meta)

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'(防误增)。

- 🏠 物理房间:色带 banner + 「+ 房间」(datalist 固定 17 项:玄关/客厅/餐厅/厨房/主卧/次卧/儿童房/书房/主卫/次卫/衣帽间/阳台/储物间/车库/影音室/茶室/健身房/其他)+ 「+ 添加设备」(房间下拉仅显示物理房间)。

- ⚙️ 系统配置:色带 banner + 展示选配向导子系统(中控/照明/.../清洁),可移除设备,不可新增。

- 📋 方案元信息:色带 banner + 文字展示(网络方案 / 目标场景),不参与造价统计

- 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. 部署与维护须知(给后续维护者)