19 KiB
界面设计说明
给维护界面的人看:颜色和样式放在哪、加一个控件该怎么做、哪些约定不能破。
美化前的状态是"零样式基础设施":没有 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(...) 引用:
改主题色时指示器跟着变,仓库里也不用存二进制素材。
这道工序有两道防线,别删:
- 生成的 SVG 必须能用
QImage读出来,否则整套指示器样式都不生效(回落原生外观); - 拼接后若还残留
@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 SET13 列且不含created_at、COALESCE排序、四个条件都用ILIKE); - 用假连接验证
save()的两条硬语义(保留人工批注、保留created_at); - 未装驱动时的可读提示、连不上时的可读提示且不泄漏密码;
- 「存储」下拉的切换行为(行切换 + holder 目标后端 + 断开旧连接)。
改完界面跑一遍:
QT_QPA_PLATFORM=offscreen uv run python smoke_test.py
要验 PostgreSQL 的真实读写,用事务版校验(对真实库零残留):
uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 --dbname <库> --user <账号>
7. 维护红线
-
页面里不写颜色、不写
setFixedWidth—— 用 token 和common.btn()。 -
不动控件属性名:
smoke_test.py直接引用issue_btn、preview、verdict、table、records等属性;改名会静默破坏测试。 -
四个页签的文本与数量不要改(
签发/台账/密钥/查看 / 校验), 台账表格保持 9 列同序同文本。 -
保持
from .common import info, warn, error+ 裸调用: 测试靠替换各模块的同名全局来消除模态弹窗,改成common.info(...)会让弹窗 阻塞无头测试。 -
QSS 不设全局
font-family(见上文)。 -
指示器的两道防线别删。
-
测试/截图/自检脚本必须用
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 上仍需要少量滚动。若要进一步压缩, 可以把"来源指纹"与"解析结果"并排放,或把「预览与签发」提到更靠上的位置 —— 但那是信息架构调整,不是样式问题。