Files
2026-09-22 16:08:12 +08:00

327 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 界面设计说明
给**维护界面的人**看:颜色和样式放在哪、加一个控件该怎么做、哪些约定不能破。
美化前的状态是"零样式基础设施":没有 QSS、没有主题、没有图标,
只有 6 处内联 `setStyleSheet` 和一套硬编码的 `setFixedWidth(72/88/96/110/140)`。
现在样式集中在两个模块里:
| 文件 | 职责 |
| --- | --- |
| `src/chongming_license_gui/theme.py` | 设计 token、全局 QSS、`apply_theme()`、语义化样式助手 |
| `src/chongming_license_gui/icons.py` | 内联 SVG 图标 → `QIcon`(无二进制素材) |
| `src/chongming_license_gui/common.py` | 控件工厂:`btn()` / `hint()` / `set_status()` / `flash()` / `section()` |
| `src/chongming_license_gui/pg_store.py` | PostgreSQL 台账后端(可选)+ `PgConfig` |
| `src/chongming_license_gui/pg_dialog.py` | PostgreSQL 连接设置对话框 |
| `tools/screenshot.py` | 生成 `docs/images/` 里的文档截图 |
| `tools/pg_check.py` | 对真实 PG 做契约一致性校验(事务内执行并回滚) |
---
## 1. 设计 token
全部定义在 `theme.py` 顶部。**改主题只改这里**(或下面的 QSS 模板),
不要在页面里写死十六进制值。
### 主色与中性色
| Token | 值 | 用途 |
| --- | --- | --- |
| `ACCENT` | `#2563eb` | 主操作按钮、选中页签、卡片标题、焦点描边 |
| `ACCENT_HOVER` / `ACCENT_PRESSED` | `#1d4ed8` / `#1e40af` | 主按钮悬停 / 按下 |
| `ACCENT_SOFT` | `#eff6ff` | 表格与列表的选中底、页签悬停底、概览条底色 |
| `BG` | `#f5f6f8` | 窗口与页签面板底色(卡片浮在它上面) |
| `SURFACE` | `#ffffff` | 卡片、输入框、导出底 |
| `SURFACE_ALT` / `SURFACE_SUNKEN` | `#fafbfc` / `#f7f8fa` | 表格斑马纹 / 表头与只读区 |
| `BORDER` / `BORDER_STRONG` | `#e3e6ea` / `#cdd3da` | 卡片描边 / 输入框与次按钮描边 |
| `TEXT` / `TEXT_MUTED` / `TEXT_DISABLED` | `#1f2328` / `#6b7280` / `#9ca3af` | 正文 / 说明文字 / 禁用 |
### 语义色
每个语义色都配一个浅底(`*_SOFT`)和描边(`*_BORDER`),用于横幅、胶囊、警示条。
| Token | 值 | 用途 |
| --- | --- | --- |
| `OK` | `#15803d` | 校验通过、已加载、主色胶囊的"正常"态 |
| `WARN` | `#b45309` | 安全提示、weak/无来源告警、未加载私钥胶囊、页脚安全条 |
| `DANGER` | `#b91c1c` | 校验失败、已过期、危险按钮(删除记录) |
| `INFO` | `#1d4ed8` | 台账"永久有效" |
| `PERMANENT` | `#6d28d9` | 台账"未生效" |
| `EXPIRING` | `#c2410c` | 台账"即将到期"。**注意**:原内联色 `#e65100` 在白底上对比度不足,这里刻意加深了一档 |
### 形状与字体
| 项 | 值 | 说明 |
| --- | --- | --- |
| `RADIUS_CARD` / `RADIUS_CONTROL` | `8px` / `5px` | 卡片 / 输入框与按钮 |
| 界面字体 | `Microsoft YaHei UI` → 苹方 → Noto CJK → Segoe UI,10pt | 由 `theme.ui_font()` + `QApplication.setFont()` 下发 |
| 等宽字体 | `Cascadia Mono` → `Consolas` → `JetBrains Mono` → 系统 FixedFont | 由 `common.mono_font()`,用于机器码/指纹/申请码 |
> ⚠️ **样式表里不要写 `font-family`**。QSS 的字体会覆盖 per-widget 的 `setFont()`,
> 而机器码、指纹这类要**逐字符核对**的文本必须保持等宽 —— 一旦被覆盖就会错位。
> 界面字体统一走 `QApplication.setFont()`(作用范围是"默认值",不会压掉 per-widget 设置)。
---
## 2. 控件规范
### 卡片(分区)
页面用 `common.section("标题")` 建卡片,返回 `(QGroupBox, QFormLayout)`。
视觉全部由 QSS 负责(白底、1px 描边、8px 圆角、标题着主色),
代码里只设间距(`section()` 内部设 `ContentsMargins(0,2,0,0)`、行距 9、列距 12)。
> 卡片留白由 QSS 的 `padding` 控制,**`section()` 里不要再设 padding** ——
> 两处都设会叠加成过大的留白。
### 按钮
一律用 `common.btn(text, role=..., icon_name=..., min_width=...)`,它负责 role、
图标着色与最小宽度。
| `role` | 外观 | 用在哪 |
| --- | --- | --- |
| 空 | 白底描边(次按钮) | 默认;浏览、刷新、复制等辅助操作 |
| `"primary"` | 主色实心 + 白字 + 白图标 + 加粗 | **每页只放一个**主操作:签发并保存 / 查询 / 续期选中 / 校验 / 生成密钥对 |
| `"danger"` | 白底 + 红字红描边,悬停浅红底 | 破坏性操作:删除记录 |
| `"link"` | 无边框主色文字 | 文字链(QSS 已备好,暂未使用) |
- 用 `min_width`(`setMinimumWidth`)而不是 `setFixedWidth`:中文与高 DPI 下不会被挤压。
- 主按钮图标自动用白色,次按钮用 `TEXT_MUTED`,危险按钮用 `DANGER` —— 不用手写颜色。
### 语义标签
| `role` | 外观 | 用途示例 |
| --- | --- | --- |
| `hint` | 灰字 | 字段下方的说明 |
| `warn` | 琥珀字 | 私钥告警、仅机器码的警示 |
| `danger` / `ok` | 红字 / 绿字 | 加载失败 / 成功 |
| `stats` / `stats-error` | 浅蓝底信息条 / 浅红底 | 台账概览(正常 / 打不开) |
| `verdict-ok` / `verdict-bad` / `verdict-idle` | 绿/红/灰**横幅** | 校验结论 |
| `chip-ok` / `chip-warn` | 圆角胶囊 | 页头的密钥状态 |
| `flash` | 绿字 | 复制后的瞬时提示(由 `common.flash()` 用完自动清除) |
运行期改 `role` 后**必须**调用 `theme.set_role()` / `theme.repolish()`,
否则 Qt 不会重算样式(`setProperty` 本身不触发重绘)。
### 状态色(台账)
`theme.STATUS_COLORS` 与 `chongming_license.STATUS_LABELS` 的键一一对应。
表格里的状态列只改**呈现**(前景色 + 加粗),**单元格文本保持原样**
(形如 `即将到期 (30d)`)—— 有回归断言盯着,别顺手改格式。
### 指示器(复选框/单选框)
QSS 画不了图形,只能用图片文件。做法是在 `theme._ensure_indicator_assets()` 里
**按 token 生成 6 个 SVG 写进临时目录**,再由 QSS `image: url(...)` 引用:
改主题色时指示器跟着变,仓库里也不用存二进制素材。
这道工序有两道防线,别删:
1. 生成的 SVG 必须能用 `QImage` 读出来,否则整套指示器样式都不生效(回落原生外观);
2. 拼接后若还残留 `@PLACEHOLDER@`,整块指示器样式**整段丢弃**。
> 原因:QSS 的 `image` 加载失败时 **Qt 不报错**,指示器会直接变成空白 ——
> 用户就看不见"签发后记入台账"到底勾没勾。开发过程中真的踩过一次
> (占位符替换把 `url()` 嵌了两层),所以现在有两道防线 + 一条回归断言。
---
## 3. 页面骨架
```
┌─ QMainWindow ────────────────────────────────────────────┐
│ 页头 QFrame[role=header]:盾牌图标 + 标题/副标题 + 密钥状态胶囊 │
├──────────────────────────────────────────────────────────┤
│ QTabWidget:签发 / 台账 / 密钥 / 查看·校验(带图标) │
│ └ 每个页签 = QScrollArea#pageScroll → 页面 QWidget │
│ └ section() 卡片… │
├──────────────────────────────────────────────────────────┤
│ 页脚 QFrame[role=footer]:锁图标 + 私钥安全提示(琥珀条) │
└──────────────────────────────────────────────────────────┘
```
### 为什么每页都套滚动容器
原实现是"页签直接装内容"。窗口 1180×940、签发页内容约 1360px,
在 1366×768 的笔记本上**底部的「签发并保存…」被窗口切掉且无法滚到**。
现在四个页签统一套 `QScrollArea(setWidgetResizable(True))`,内容再长也能滚到。
> 注意:套了容器后 `tabs.widget(index)` 返回的是 `QScrollArea` 而不是页面对象。
> `MainWindow._on_tab_changed` 因此改成按 `self._pages[index]` 比对,
> 页面 → 容器的映射在 `self._area_by_page`。新增页签时别忘了同步这两处。
### 打开时就用最小尺寸
窗口打开即最小尺寸(`resize(self.minimumSize())`,当前 940×640):小屏笔记本上
不会顶出屏幕,需要更大自己拉。页签都在滚动容器里,窗口小只是多滚动,不会把按钮挤没。
> 代价:940 宽时台账表格(9 列合计 1322px)要横向滚动才能看到最右侧的列;
> 表格一直是横向可滚的,只是露出多少列不同。改这个值时在 1366×768 上试一眼。
> 文档截图仍按 1240 宽渲染(`tools/screenshot.py` 的 `WINDOW_WIDTH`),比实际窗口宽。
### 页头的密钥状态胶囊
显示"私钥已加载 · 指纹 xxxx"(绿)或"未加载私钥 · 点击到「密钥」页加载"(琥珀),
点击跳转到密钥页。标题栏同时也带指纹(多开窗口/截图沟通时靠它区分)。
---
## 4. 怎么改
| 想做的事 | 改哪里 |
| --- | --- |
| 换主色/配色 | `theme.py` 顶部的 token;QSS 与指示器 SVG 都会跟着变 |
| 调卡片留白、圆角、按钮尺寸 | `theme.py` 的 `_QSS` 模板(`@TOKEN` 占位符) |
| 加一个新图标 | `icons.py` 的 `_SHAPES` 加一条 24×24 描边路径,然后 `icons.icon("名字")` |
| 加一种语义样式 | `theme.py` 的 `_TOKENS` 加色值 → `_QSS` 加 `QLabel[role="..."]` 规则 |
| 换界面字体/字号 | `theme.ui_font()` |
| 换等宽字体 | `common._MONO_CANDIDATES` |
| 改台账的 PG 行为(SQL/方言/建表) | `pg_store.py`:SQL 提在模块级常量里,改完跑 `smoke_test.py` 的「PG 适配层」小节 |
| 改 PG 连接对话框字段 | `pg_dialog.py` + `pg_store.PgConfig`(记得同步 `from_settings` / `save`) |
| 重新生成文档截图 | `uv run python tools/screenshot.py` |
### QSS 模板的占位符
`_QSS` 里用 `@TOKEN` 写色值(**不用** `str.format`/f-string:样式表里的 CSS 花括号
会被当成占位符)。`_substitute()` 按名字**从长到短**替换,避免 `@TEXT` 吃掉 `@TEXT_MUTED`;
占位符结尾的 `@` 可写可不写(`@TEXT` 与 `@TEXT@` 都行)。
### 图标
单色描边图标(feather 风格),运行时用 `QSvgRenderer` 光栅化到 2× 位图并标记
devicePixelRatio,按 `(名称, 颜色, 尺寸)` 缓存。`PySide6.QtSvg` 不可用时
`icon()` 返回空 `QIcon` —— 少个图标不影响功能,绝不能因此报错。
---
## 5. 台账的双后端
台账可以存本地 SQLite,也可以存共享 PostgreSQL。设计上刻意让**页面完全不知道**
后端是谁:
```
RegistryTab ──> RegistryHolder ──┬─> chongming_license.Registry (本地 SQLite)
└─> pg_store.PostgresRegistry (PostgreSQL)
```
`PostgresRegistry` **继承**库的 `Registry`,只重写与存储引擎相关的 6 个方法
(`__init__` / `query` / `get` / `save` / `update_note` / `delete`),
`stats` / `find_by_machine_code` / `export_licenses` / `export_csv` / `close` /
`_row_to_record` **全部继承** —— 于是 CSV 列序、`.lic` 导出文件名与客户目录转义、
状态分桶统计都不可能和 SQLite 版出现第二套实现。
几条必须保持的语义(改 PG 后端时逐条对照):
| 语义 | 为什么 |
| --- | --- |
| 状态筛选留在 Python 侧(用 `LicenseRecord.status`) | 状态有优先级,下推成 SQL 条件会互相重叠,`stats()` 各项之和会不等于总数 |
| `ON CONFLICT (lic_id) DO UPDATE SET` 恰好 13 列,**不含 `created_at`** | 首次入库时间要保留;覆盖时只刷新 `updated_at` |
| 覆盖已有记录时保留台账人工批注(除非 `overwrite_note=True`) | 载荷 `note` 是签名内容,台账 `note` 是厂商批注,同名不同用途 |
| 日期列用 TEXT 存、`ORDER BY COALESCE(expires_at,'9999-12-31')` | 库依赖 ISO 日期字符串比较排序,NULL(永久)排最后 |
| 过滤用 `ILIKE` 而不是 `LIKE` | SQLite 的 `LIKE` 对 ASCII 大小写不敏感,用 PG 的 `LIKE` 会让同一条件在两端结果不同 |
| `autocommit=True`(界面路径) | 镜像 SQLite 的逐语句提交;否则长连接会长期 `idle in transaction` |
| schema 名必须校验后加引号 | psycopg 的参数占位符不能用于标识符,不校验就是注入面 |
界面侧:「台账库」卡片里用 `setRowVisible` 在「路径」行与「连接」行之间切换
(不用 `QStackedWidget`:那会破坏表单标签对齐)。切换后端时**主动断开**当前连接,
避免"界面显示一个后端、实际读写另一个"。密码只存 `PgConfig`,且:
- 不进日志、不进标签、不进错误信息(`display()` 与连接 kwargs 都刻意排除它);
- 只有勾选"记住密码"才写 QSettings,取消勾选即删除。
### 5.1 两个"重签"行操作
台账里能改授权内容的两个操作都是**重签**(签名覆盖整个载荷,改任何字段只能重签):
| 操作 | 变了什么 | `source` | 旧记录批注 |
| --- | --- | --- | --- |
| 续期选中…(`RenewDialog`) | 到期日(`not_before` 不传 = 从现在起算) | `renew` | `已续期 → <新编号>` |
| 改插件…(`ChangePluginsDialog`) | 只改 `plugins`;`not_before`/`expires_at` **逐字写回** | `plugins` | `已变更插件 → <新编号>` |
两者共用 `RegistryTab._resign_and_save()`(选路径 → 复用台账指纹重签 → 写盘 → 入库
→ 批注),区别只在传进去的日期与文案。改插件对话框刻意**不提供**有效期选项:
要延长有效期就走续期,免得"只想加个插件"顺手把到期日也改了。
插件勾选由 `plugin_picker.PluginPicker` 提供(签发页与改插件对话框共用)。它按
"构建器"形态接收调用方的 `QFormLayout` 并直接往里加行,签发页排版因此与抽出之前一致;
`changed` 信号供对话框实时算"新增/移除"差异。老白名单靠 `ensure_types()` 补进列表 ——
项目里已经删掉的插件 type 也必须看得见、勾得回来,否则操作者没法对照"原来授权了什么"。
对话框里两个刻意的取舍,改版式时别改回去:
- `show_root=False`:省掉「项目根」行与重新加载按钮。项目根是签发页的设置,
两个地方都能改反而容易改岔;对话框里宽度与高度优先留给操作本身。
- 原授权摘要压成 3 行等宽只读块(`make_mono_label`),与勾选列表同卡。
这个对话框只干"换白名单"一件事,**高度要收在 ~570px 以内** ——
768 高的笔记本屏上才放得下(对话框没有滚动区,超了就是按钮点不到)。
## 6. 回归断言(改界面时先看这里)
`smoke_test.py` 开头有一段"界面主题"检查,专门盯那些**出错时不会报错、只会静默变丑**的地方:
- 样式表已应用、无残留占位符;
- 主操作用主色实心、次按钮保持白底(防 role 写错);
- 危险按钮确实带危险色;
- 勾选态指示器按主题渲染(防指示器变空白);
- 主色/卡片底/页面底/警示色都真的画到了界面上;
- 台账状态列用主题状态色、加粗、机器码列等宽;
- 台账「改插件」对话框:默认预勾选原白名单、默认生成新编号、**没有**有效期选项、
差异行按"与原来一致 / 新增 / 移除"变化并在移除时亮出拒载告警、一个都不勾时拒绝提交;
- 改插件是**重签**:载荷里除 `lic_id`/`issued_at`/`note`/`plugins` 外逐字不变
(`not_before`/`expires_at`/`sources` 原样写回),新文件可独立验签;
- **配置隔离自证**:`settings.fileName()` 必须落在临时目录里。
末尾还有一段"PostgreSQL 后端"检查,**不需要数据库服务器**也能跑:
- SQL 形状(INSERT 15 列列序、`DO UPDATE SET` 13 列且不含 `created_at`、
`COALESCE` 排序、四个条件都用 `ILIKE`);
- 用假连接验证 `save()` 的两条硬语义(保留人工批注、保留 `created_at`);
- 未装驱动时的可读提示、连不上时的可读提示且**不泄漏密码**;
- 「存储」下拉的切换行为(行切换 + holder 目标后端 + 断开旧连接)。
改完界面跑一遍:
```bash
QT_QPA_PLATFORM=offscreen uv run python smoke_test.py
```
**要验 PostgreSQL 的真实读写**,用事务版校验(对真实库零残留):
```bash
uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 --dbname <库> --user <账号>
```
## 7. 维护红线
1. **页面里不写颜色、不写 `setFixedWidth`** —— 用 token 和 `common.btn()`。
2. **不动控件属性名**:`smoke_test.py` 直接引用 `issue_btn`、`preview`、`verdict`、
`table`、`records` 等属性;改名会静默破坏测试。
3. **四个页签的文本与数量不要改**(`签发/台账/密钥/查看 / 校验`),
台账表格保持 **9 列同序同文本**。
4. **保持 `from .common import info, warn, error` + 裸调用**:
测试靠替换各模块的同名全局来消除模态弹窗,改成 `common.info(...)` 会让弹窗
阻塞无头测试。
5. **QSS 不设全局 `font-family`**(见上文)。
6. **指示器的两道防线别删**。
7. **测试/截图/自检脚本必须用 `common.isolated_settings()` 建配置,并调用
`common.assert_isolated_settings()` 自证落点。**
> 这条是踩坑换来的:`QSettings.setDefaultFormat(IniFormat)` 在本环境里**没有**
> 改变两参数构造 `QSettings(org, app)` 的落点,`fileName()` 仍指向注册表
> `HKCU\Software\chongming\license-gui`。于是脚本以为在写临时配置,实际把测试值
> (甚至数据库密码)写进了用户真实配置,还顺手 `clear()` 掉了用户自己的设置。
> 现在一律用四参数构造(format + scope 显式指定),并**回读 `fileName()` 断言**
> 它在临时目录下 —— 隔离这件事不能靠"我以为"。
## 8. 已知取舍
- **基底样式用 Fusion**:本主题大量依赖 QSS,Windows 原生样式与 QSS 混用会出现
"一半原生一半自绘";Fusion 完全可自绘、跨平台一致,也避免系统深色主题下
浅色卡片"半明半暗"。代价是与系统原生控件观感略有差异。
- **只做了浅色主题**。token 已按主题化组织,要加深色变体的话,把 `_TOKENS`
换成另一套色值即可(`build_qss()` 不用改);指示器 SVG 也会跟着变。
- **签发页内容偏长**(约 1360px),在 1080p 上仍需要少量滚动。若要进一步压缩,
可以把"来源指纹"与"解析结果"并排放,或把「预览与签发」提到更靠上的位置 ——
但那是信息架构调整,不是样式问题。