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

19 KiB
Raw Permalink Blame History

界面设计说明

给维护界面的人看:颜色和样式放在哪、加一个控件该怎么做、哪些约定不能破。

美化前的状态是"零样式基础设施":没有 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 目标后端 + 断开旧连接)。

改完界面跑一遍:

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. 维护红线

  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 上仍需要少量滚动。若要进一步压缩, 可以把"来源指纹"与"解析结果"并排放,或把「预览与签发」提到更靠上的位置 —— 但那是信息架构调整,不是样式问题。