# 内容页架构
> **用途**:说明内容页体系在代码中**实际如何工作**(框架机制 / 页面插件 / 数据流 / 键与绘制行为 / 内存),
> 并给出新增页面的标准步骤。全文与当前代码一一对应;页面像素几何(行槽/选中带/页脚实测值)
> 见 `菜单页面设计.md` §九。
> **配套文档**:`UI_设计架构.md`(§1.2 文件地图 / §4.7 内容页 / §5.6 常量样式)、`菜单页面设计.md`(§九 内容页)、
> `UI_界面制作流程.md`(参考图→代码管线)、`UI_自动化测试流程.md`(回归门禁与黄金参考)。
---
## 一、什么是内容页
菜单**叶子节点** ENTER 后进入的**独立全屏页面**:新建一块 LVGL 屏幕,全部内容(装饰 + 标题 + 行 + 页脚)
由框架一次性自绘(**无子对象**),ESC 返回菜单屏。当前已接 5 页:
| 页面 | 菜单节点 | 形态 | 数据 | 描述符对象 |
|---|---|---|---|---|
| 装置信息 | node 1 | 静态信息页(6 行 + 副标题,不分页) | Flash 表 | `ui_page_device_info_desc` |
| 交流量 | node 3 | 列表页(24 项,8 项/页,共 3 页) | Flash 表(第 1 页实测,2/3 页占位) | `ui_page_ac_desc` |
| 直流量 | node 4 | 列表页(3 项,单页) | Flash 表 | `ui_page_dc_desc` |
| 网口设置 | node 30(通讯参数子项) | 列表页(5 行 1 页,动态值)+ 可编辑(`edit_capable`,↑↓调字节/←→移光标/ENTER 确认;保存行 ENTER → 投递保存请求,属主任务 defaultTask 写盘 + 立即应用(免重启),行值随交接状态 ENTER/WAIT/OK/ERR) | `net_cfg` RAM 副本(defaultTask 上电自 Flash 加载;netTask 回写实际 MAC 影子) | `ui_page_net_cfg_desc` |
| 用户密钥 | 需密码叶子(如 node 8 参数) | 表单/操作页(draw_cb + key_cb;无行、无"浅 V"、无页脚) | arm 装载:完成回调(携授予权限);页面比对两个编译期密码 | `ui_page_password_desc` |
设计核心是 **"框架 + 页面插件"**:框架 `ui_content_page` 只持**机制**(屏/容器生命周期、焦点、
按键分发、通用绘制),每页的**数据/几何/钩子**由独立 `ui_page_xxx` 模块经描述符 `content_page_def_t`
提供;两者只以描述符相遇——框架不持任何页面数据,页面模块不碰任何 LVGL 对象。
### 1.1 系统总览
```mermaid
flowchart TD
K(["按键 / LVGL 刷新
(均在 uiTask 上下文,10ms 周期)"]) --> M["逻辑层 ui_menu.c
导航状态机(17 B)+ 键语义
menu_content_page_for:叶子节点 → 页 ID"]
subgraph FW["框架 ui_content_page.c/h —— 只有机制,零页面数据"]
S[("描述符表 s_defs[]
页 ID → const content_page_def_t *
(几何 + 数据源 + 4 钩子)")]
一级菜单["屏/容器生命周期
按需建毁 · 焦点 · 切屏"]
二级菜单["按键分发
内置 ↑↓/ESC + key_cb 钩子"]
三级菜单["通用绘制(LV_EVENT_DRAW_MAIN)
浅 V → 标题/副标题 → 行 → 选中带 → 页脚 → draw_cb"]
end
M -- "ENTER:ui_content_page_open(id, perm)" --> S
S -- "描述符驱动" --> 一级菜单
S -- "描述符驱动" --> 二级菜单
S -- "描述符驱动" --> 三级菜单
二级菜单 -- "ESC/←:close()" --> 一级菜单
subgraph PG["页面插件 —— 数据(Flash)+ 几何 + 钩子实现,零 LVGL 对象"]
P1["ui_page_device_info
装置信息(静态信息页)"]
P2["ui_page_ac
交流量(列表页)"]
P3["ui_page_dc
直流量(列表页)"]
P4["ui_page_password
用户密钥(表单/操作页)"]
PN["ui_page_xxx
扩展点:每新增页一文件"]
end
P1 -- "const 描述符(Flash)" --> S
P2 -- "const 描述符(Flash)" --> S
P3 -- "const 描述符(Flash)" --> S
P4 -- "const 描述符(Flash)" --> S
PN -- "const 描述符(Flash)" --> S
三级菜单 -- "ui_deco_draw" --> D["ui_deco(共享)
顶部“浅 V”装饰"]
三级菜单 -- "动态行:refresh / count_cb" --> PG
二级菜单 -- "页面自有键:key_cb" --> PG
```
读图要点:
- **数据只向下一层流**:按键 → 逻辑层 → 框架;页面插件不主动调任何人,只被动提供描述符与钩子
- **框架 ↔ 页面只以描述符相遇**:框架不持页面数据,页面不碰 LVGL 对象
- 反向调用(`refresh/count_cb/draw_cb/key_cb`)是**框架回调页面**,方向与依赖方向相反
- 菜单根框与内容页共用同一"浅 V"装饰(`ui_deco` 模块,几何唯一定义处)
## 二、系统组成
| 文件 | 角色 | 一句话职责 |
|---|---|---|
| `ui_content_page.c/h` | **框架** | 屏/容器生命周期 + 按键分发 + 描述符驱动通用绘制 + 描述符表;公开 `content_page_id_t` / `content_page_def_t` / `cp_row_t` 与 4 个 API |
| `ui_page_device_info.c/h` | 页面插件 | 装置信息页:6 行 Flash 表 + 描述符对象 `ui_page_device_info_desc` |
| `ui_page_ac.c/h` | 页面插件 | 交流量页:24 行 Flash 表 + 描述符对象 `ui_page_ac_desc` |
| `ui_page_dc.c/h` | 页面插件 | 直流量页:3 行 Flash 表 + 描述符对象 `ui_page_dc_desc` |
| `ui_page_password.c/h` | 页面插件 | 用户密钥页:3 数字框自绘(draw_cb)+ ↑↓/←→/ENTER 键处理(key_cb)+ arm API + 描述符对象 `ui_page_password_desc` |
| `ui_page_net_cfg.c/h` | 页面插件 | 网口设置页:5 行(IP/掩码/网关/MAC + 保存)动态值 + 可编辑(draw_cb 反显活动字节 + key_cb 调字节/移光标/保存)+ `init_cb` 重置运行态 + 描述符对象 `ui_page_net_cfg_desc`(数据源 `net_cfg`) |
| `ui_deco.c/h` | 共享 | 顶部"浅 V"装饰(3 段线 + culling),菜单根框/内容页共用 |
| `ui_menu.c` | 逻辑层 | `menu_content_page_for` 叶子节点 → 页 ID 映射;ENTER 普通叶子调 `ui_content_page_open`;受保护叶子先 arm + 开密码页,确认一致后回调**先关页再续做进入动作**(`menu_password_ok`) |
| `ui_menu_view.c` | 逻辑层依赖 | `ui_content_page_close` 经 `ui_menu_view_container()` 把焦点切回菜单持久容器 |
依赖方向:`ui_menu(逻辑)→ ui_content_page(框架)→ ui_page_xxx(页面数据)`;
页面模块只依赖框架头文件(取 `content_page_def_t` / `cp_row_t` 类型),**不反向调用任何人**。
## 三、框架机制(ui_content_page.c)
### 3.1 公开 API
| API | 作用 | 关键行为(代码实际) |
|---|---|---|
| `bool ui_content_page_open(id)` | 开页 | ① 查 `s_defs[id]`:NULL → 打 `ERROR` 日志并返回 false(枚举/表漂移防护)② `lv_obj_create(NULL)` 建屏 + 全屏容器(flat 常量样式 + `IGNORE_LAYOUT` + `CLICK_FOCUSABLE`)③ 容器**先**入默认输入组并获焦 ⓸ `lv_screen_load` 切屏 |
| `void ui_content_page_close()` | 关页 | ① 焦点**先**回菜单持久容器(防输入组焦点悬空)② 切回菜单屏(菜单屏对象未动,原样还原)③ 再删容器+屏,池内存回落 |
| `void ui_content_page_invalidate()` | 整页重绘 | 失效页面容器;供表单/操作页自身状态变化后调用(无页打开时空操作) |
| `bool ui_content_page_is_open()` | 查询 | `s_open != CONTENT_PAGE_COUNT` |
框架持有的运行状态(全部 4 个变量,约 20 B):
```c
static content_page_id_t s_open; /* 当前打开的页(CONTENT_PAGE_COUNT = 未打开) */
static uint16_t s_sel; /* 列表页选中行(全局下标,跨页连续;每次进入清零) */
static lv_obj_t *s_scr; /* 页面屏幕(进入时建、返回时毁) */
static lv_obj_t *s_cont; /* 页面全屏容器(焦点/键入口 + 自绘载体) */
```
### 3.2 页面生命周期:一次 ENTER → 一次 ESC
```mermaid
sequenceDiagram
participant U as uiTask(按键 / 10ms 刷新)
participant M as 逻辑层 ui_menu.c
participant F as 框架 ui_content_page
participant P as 页面模块 ui_page_xxx
participant D as ui_deco(共享装饰)
Note over M,F: 菜单屏:焦点 = 菜单持久容器,按键归逻辑层
U->>M: ENTER(LV_EVENT_KEY → menu_key_cb)
M->>M: menu_content_page_for(node):叶子节点 → 页 ID
M->>F: ui_content_page_open(id)
F->>F: s_defs[id] 查描述符(NULL → 返回 false + 错误日志)
F->>F: 建屏 + 全屏容器(flat 常量样式)→ 入输入组获焦 → 切屏
Note over F: 内容页:焦点 = 页面容器,按键归框架键分发
F->>D: DRAW_MAIN:ui_deco_draw(浅 V)
F->>F: 标题/副标题(居中)
F->>P: refresh(row, name, value)(动态页逐行取数;同帧同步光栅化,栈缓冲安全)
F->>P: count_cb(动态行数)
F->>F: 行 + 选中白带 + 页脚页码
F->>P: draw_cb(表单/操作页:自定义内容)
U->>F: ESC/←(LV_EVENT_KEY → 框架键分发)
F->>F: close():焦点先回菜单持久容器 → 切回菜单屏 → 再销毁页面屏
Note over M: 菜单导航状态(s_depth/s_sel/s_scroll)原样保留,返回即还原
```
要点:
- **导航状态不压层**:进内容页不动菜单 `s_depth/s_sel/s_scroll`,ESC 返回即原样还原
- 菜单屏对象**保留不销毁**(`lv_screen_load` 来回切,屏上对象原样还原)
- 页面按需建毁:任一时刻至多一个"屏 + 容器"存在
- 焦点/键入口在切屏**之前**接线(先入组获焦,后 `lv_screen_load`),按键立即由框架接管
### 3.3 描述符表与页面注册
```c
/* ui_content_page.c(实际代码) */
static const content_page_def_t *const s_defs[CONTENT_PAGE_COUNT] = {
[CONTENT_PAGE_DEVICE_INFO] = &ui_page_device_info_desc,
[CONTENT_PAGE_AC] = &ui_page_ac_desc,
[CONTENT_PAGE_DC] = &ui_page_dc_desc,
[CONTENT_PAGE_PASSWORD] = &ui_page_password_desc,
[CONTENT_PAGE_NET_CFG] = &ui_page_net_cfg_desc,
};
```
- 每页一行:`页 ID → &描述符对象`;绘制/按键逻辑一律经 `s_defs[s_open]` 取当前页描述符
- **描述符是 extern const 对象**(头文件声明、页面模块 `.c` 定义),框架表直接存其**地址**
> ⚠️ 为什么不用"访问器函数"建表:ARMCC v5 禁止用函数名初始化对象指针数组(error #144,
> 拆分实施时实测命中)。extern const 对象模式与菜单数据层 `g_menu_tab[]` 一致,且天然落 Flash。
- **NULL 防护**:`open()` 时查表为 NULL(枚举加了值但表漏加行)→ 拒开 + `ERROR` 日志,不崩溃
### 3.4 按键分发(cp_key_cb)
```mermaid
flowchart TD
A(["按键(焦点容器 → LV_EVENT_KEY → cp_key_cb)"]) --> B{"ESC ?"}
B -- 是 --> C["ui_content_page_close()
关页返回菜单(框架保证,恒不可拦截)"]
B -- 否 --> L{"← ?"}
L -- "key_intercept=1 且
key_cb(←) 返回 true" --> E["页面已处理(表单/操作页自有键)
框架跳过内置语义"]
L -- "未置标志 / 钩子未处理" --> C
L -- 否 --> D{"key_cb 钩子非空
且返回 true ?"}
D -- 是 --> E
D -- 否 --> F{"↑ / ↓ ?"}
F -- 是 --> G{"selectable=1 且
总行数 > 0 ?"}
G -- 是 --> H["移选(首尾环绕,跨页连续)
选中行越界 → 重置首行(防动态行数缩小)
lv_obj_invalidate 触发重绘"]
G -- 否 --> I["不响应(信息页 / 空表)"]
F -- 否 --> J["其余键值:忽略"]
```
行为细节(与代码一致):
- **ESC 恒返回**:最先判断,`key_cb` 无法拦截(框架保证页面可退出)
- **← 每页可拦截**:`key_intercept=1` 时 ← **先**交 `key_cb`(true = 页已处理不关页,如密码页光标移动);未置标志或钩子未处理则照旧关页
- **key_cb 先于内置 ↑↓**:返回 `true` = 页已处理,框架不再执行内置移选
- **↑↓ 移选**:`(sel + total ∓ 1) % total` 首尾环绕;`s_sel` 是全局下标,跨页连续
- **越界防护**:`s_sel >= total` 时先重置首行(为动态行数可能缩小而设;静态页恒不触发)
- 移选只改 `s_sel` + `lv_obj_invalidate(s_cont)` 触发重绘,**不重建任何对象**
### 3.5 通用绘制链(cp_draw_cb,LV_EVENT_DRAW_MAIN)
```mermaid
flowchart TD
A(["LV_EVENT_DRAW_MAIN → cp_draw_cb"]) --> B["① 浅 V 装饰:ui_deco_draw(共享模块,内置 culling;hide_deco=1 跳过)
② 标题(居中,每页 title_y / 默认 CP_TITLE_Y=y2) ⓢ 副标题(居中,y=subtitle_y;仅非空页)"]
B --> C["④ 行区块
列表页:窗口 = 选中行所在页(首可见行 = (sel/每页数)×每页数)
信息页:全量;行槽位置固定,数据随页切换"]
C --> C1{"行数据来源"}
C1 -- "静态:rows 表非空" --> C2["直绘 Flash 表(text_static=1,不拷内存)"]
C1 -- "动态:refresh 非空" --> C3["refresh() 逐行取入栈缓冲(32 B×2,同帧同步光栅化)"]
C2 --> D
C3 --> D["逐行:culling 判交 → 选中行白带(x=2..156,高 band_h)
→ 左/右两列 lv_draw_label(选中黑字,其余白字)"]
D --> E["⑤ 页脚“第 X 页,共 Y 页”(y=143,x=6;hide_footer=1 跳过)
列表页:随选中行现算;信息页:fixed_cur/total 固定值"]
E --> F{"draw_cb 非空 ?"}
F -- 是 --> G["⑥ 页面自定义内容(表单/操作页,在内置区块之后)"]
F -- 否 --> H(["绘制完成"])
G --> H
```
行为细节(与代码一致):
- **行窗口**:`first = (s_sel / rows_per_page) * rows_per_page`,`cnt` 截断到 `rows_per_page`
(末页不足整页);信息页(`selectable=0`)全量绘制
- **culling**:每个区块(标题/副标题/行条带/页脚)先判"重绘区 ∩ 该区块 ≠ ∅"(`cp_areas_intersect`,
与 LVGL `lv_area_intersect` 等价的本地实现,避免依赖私有头),不交则整块跳过
- **选中带**:`band_h > 0` 且该行选中 → 整行白带 `x=2..156`(参考图实测),文字随之黑字
- **文本**:静态行 `text_static=1`(Flash 字符串不拷贝);动态行与页脚页码用栈缓冲
(`lv_draw_label` 在同一刷新周期内**同步**光栅化,指针仅需调用期间有效)
- **页脚**:`lv_snprintf` 现算(MSVC 对 `snprintf` 中文格式串误报 C4819,故用 LVGL 自带 `lv_snprintf`)
> ⚠️ **`lv_draw_label` 折行陷阱**:`lv_draw_label` 的换行宽度取 **`coords` 宽度**(未置 `LV_TEXT_FLAG_EXPAND` 时 `w = lv_area_get_width(coords)`)。在**窄区域**(光标盒 / 小方框)里传入**长于该区域的文本**时,LVGL 会按该宽度**折行并逐行向下绘制**,溢出到下方其它行与空白区(表现为"下方弹出一串字符")。规则:窄区域只传"恰好该区域宽度的文本"(如网口设置光标段只传 3 个字符),长文本请在整行宽条带内绘制。
- 容器样式为**常量样式**(`LV_STYLE_CONST_INIT`,零 LVGL 池分配),见 `UI_设计架构.md` §5.6
## 四、页面描述符(content_page_def_t)
```c
/* ui_content_page.h(实际代码) */
typedef struct {
const char *title; /* 标题(浅 V 区居中) */
const char *subtitle; /* 副标题;NULL = 无(仅信息页有) */
int32_t subtitle_y; /* 副标题放置 y(仅 subtitle 非空时有效) */
int32_t title_y; /* 标题放置 y;0 = 框架默认(CP_TITLE_Y) */
uint8_t hide_deco; /* 1 = 不绘"浅 V"装饰(密码页跟随实机) */
uint8_t hide_footer; /* 1 = 不绘页脚页码(密码页跟随实机:无页脚) */
uint8_t selectable; /* 1 = 列表页(↑↓移选+分页);0 = 信息页(静态行) */
uint8_t key_intercept; /* 1 = LV_KEY_LEFT 先交 key_cb(true = 页已处理不关页);ESC 恒不可拦截 */
int32_t top_y; /* 首行槽顶 y */
int32_t pitch; /* 行距(相邻行槽顶间距) */
int32_t band_h; /* 选中白带高(0 = 无选中带) */
int32_t text_dy; /* 文本放置相对行槽顶的垂直偏移 */
int32_t left_x; /* 左列 x(信息页=标签、列表页=名) */
int32_t right_x; /* 右列 x(值) */
uint8_t rows_per_page; /* 每页可见行数(0 = 不分页) */
uint16_t row_count; /* 总行数(静态页;动态页用 count_cb) */
const cp_row_t *rows; /* 静态数据表(Flash);NULL = 动态页 */
uint8_t fixed_cur_page; /* 信息页页脚"第 X 页"固定显示值 */
uint8_t fixed_total_pages; /* 信息页页脚"共 Y 页"固定显示值 */
/* —— 扩展钩子(全可选;NULL = 纯内置行为;均运行在 uiTask 上下文)—— */
void (*refresh)(uint16_t row, char *name, char *value); /* 动态行:逐行取数(名/值) */
uint16_t (*count_cb)(void); /* 动态行数(记录页) */
void (*draw_cb)(lv_layer_t *layer, const lv_area_t *clip); /* 表单/操作页:自定义内容 */
bool (*key_cb)(lv_key_t key); /* 页面自有键;true = 页已处理 */
} content_page_def_t;
```
字段一览(含当前 4 页实际值):
| 字段 | 类型 | 作用 | 装置信息 | 交流量 | 直流量 | 用户密钥 |
|---|---|---|---|---|---|---|
| `title` | `const char *` | 标题(浅 V 区居中) | "装置信息" | "交流量" | "直流量" | "用户密钥" |
| `subtitle` | `const char *` | 副标题;NULL = 无 | "馈线自动化终端" | NULL | NULL | NULL |
| `subtitle_y` | `int32_t` | 副标题放置 y | 25 | — | — | — |
| `title_y` | `int32_t` | 标题放置 y(0 = 框架默认) | 0 | 0 | 0 | 44 |
| `hide_deco` / `hide_footer` | `uint8_t` | "浅 V" / 页脚开关 | 0 / 0 | 0 / 0 | 0 / 0 | 1 / 1 |
| `selectable` | `uint8_t` | 1 = 列表页(↑↓+分页);0 = 信息页 | 0 | 1 | 1 | 0 |
| `key_intercept` | `uint8_t` | ← 拦截标志(先交 key_cb) | 0 | 0 | 0 | 1 |
| `top_y` | `int32_t` | 首行槽顶 y | 40 | 24 | 24 | —(无行) |
| `pitch` | `int32_t` | 行距 | 15 | 14 | 14 | —(无行) |
| `band_h` | `int32_t` | 选中白带高(0 = 无) | 0 | 13 | 13 | —(无行) |
| `text_dy` | `int32_t` | 文本垂直偏移 | 0 | 0 | 0 | —(无行) |
| `left_x` / `right_x` | `int32_t` | 左列(标签/名)/ 右列(值)x | 6 / 86 | 7 / 101 | 7 / 101 | —(无行) |
| `rows_per_page` | `uint8_t` | 每页行数(0 = 不分页) | 0 | 8 | 8 | 0 |
| `row_count` | `uint16_t` | 总行数(静态页) | 6 | 24 | 3 | 0 |
| `rows` | `const cp_row_t *` | 静态数据表(Flash) | 6 行 | 24 行 | 3 行 | NULL |
| `fixed_cur_page` / `fixed_total_pages` | `uint8_t` | 信息页页脚固定值 | 10 / 1 | — | — | — |
| `refresh` / `count_cb` / `draw_cb` / `key_cb` | 函数指针 | 扩展钩子 | 全 NULL | 全 NULL | 全 NULL | draw_cb + key_cb(余 NULL) |
扩展钩子(机制已实现;密码页用 `draw_cb` + `key_cb`,其余 3 页未用):
| 钩子 | 签名 | 框架调用时机(uiTask 上下文) | 用途 |
|---|---|---|---|
| `refresh` | `void (uint16_t row, char *name, char *value)` | 绘制行区块时,每行一次、`lv_draw_label` 之前 | 动态行:逐行取名/值(遥信量、记录页等) |
| `count_cb` | `uint16_t (void)` | 绘制(行窗口/页脚)与按键(移选取模)经 `cp_row_total()` 取行数 | 动态行数(记录查询类) |
| `draw_cb` | `void (lv_layer_t *layer, const lv_area_t *clip)` | 绘制链末尾(内置区块之后) | 表单/操作页自定义内容 |
| `key_cb` | `bool (lv_key_t key)` | 键分发时(ESC 之后、内置 ↑↓ 之前) | 页面自有键(ENTER 确认等);返回 true = 页已处理 |
### 4.1 表单/操作页首个应用:密码页(已落地)
实机(淮源电力 F30)已有"用户密钥"页:进入某些子叶面前须先输入密码(实机照片见
`mockups/密码页面实际机器的示例图片.jpg`)。它是本项目**第一个表单/操作页**,落地形态即
`draw_cb` + `key_cb` 组合。
**功能规则(已确认)**
- 密码共 **3 位**;子页(叶子)可配置"是否需要密码页"(行尾 ` #P`);密码页按所输密码**授权**:
查看密码 → 只读权限 / 修改密码 → 修改权限(只有修改权限进入后才能改参数);
密码值为编译期配置(`ui_pwd_def.h`,查看/修改各一个,改值 = 重新编译)
- 需密码叶子 ENTER:先开密码页;一致后按授予权限进入目标页(页面据此只读/可编辑);ESC 随时返回菜单
- 无密码叶子直接进,默认只读权限
- 键语义(全部由页面 `key_cb` 处理;`selectable=0`,框架内置 ↑↓ 移选不适用):
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 调整光标位数字值(0–9,环绕:9↔0) |
| ← / → | 移动光标(反显框)左/右(环绕:首框↔末框) |
| ENTER | 确认:修改密码 → 关密码页并以修改权限打开目标页;查看密码 → 以只读权限打开;不一致 → 3 框清空回 `0`、光标回首框,重输(已确认) |
| ESC | 返回菜单(框架保证,不可拦截) |
**实机视觉(照片;✅ 已确认按此实现)**
- 标题"用户密钥"居中、位于屏中上部(约屏高 1/4 处);**无**"浅 V"装饰、**无**页脚
- 3 个方框单行居中(约屏高 1/2 处),均**预填 `0`**:首框**反显(黑底白字)= 光标位**,
其余 2 框白底黑字、细边框
**框架映射(描述符配置)**
| 字段 | 值 |
|---|---|
| `selectable` | 0(无内置移选) |
| `rows` / `row_count` | NULL / 0 |
| `draw_cb` | 非空:绘制标题 + 3 个数字框(光标框反显) |
| `key_cb` | 非空:↑↓ / ←→ / ENTER 按上表 |
| `title` | "用户密钥"(y≈45 中上、"浅 V"关 —— 跟随实机;需下方 ⚠️ 扩展 2) |
**✅ 框架扩展(已落地,见 §3.4/§3.5)**
1. **← 键拦截**:描述符 `key_intercept`——置位时 `LV_KEY_LEFT` **先**交 `key_cb`(true = 页已处理不关页),
未置标志或钩子未处理则照旧关页;`LV_KEY_ESC` **不受**拦截("页面恒可退出"的框架保证不放松)。
其余 3 页不置标志,行为完全不变
2. **每页标题 y / "浅 V" / 页脚开关**:描述符 `title_y`(0 = 默认 CP_TITLE_Y)/ `hide_deco` / `hide_footer`;
密码页取 title_y=44、双开关置位,跟随实机照片
**设计确认进度**
| # | 事项 | 状态 |
|---|---|---|
| 1 | 密码不一致行为 | ✅ 已确认:清空重输(3 框回 `0`、光标回首框) |
| 2 | 密码值的存放与设置 | ✅ 已确认(2026-09-09 修订):仅编译期配置(`ui_pwd_def.h`,查看/修改各一个值,按所输密码授权),不做运行期设置页、不落 Flash(用户定案:密码只编译期写入) |
| 3 | 视觉风格 | ✅ 已确认:跟随实机照片(无"浅 V"、标题中上)→ 需 ⚠️ 扩展 2 |
| 4 | ← 键冲突解法 | ✅ 已确认:方案 A 描述符拦截开关(`key_intercept`);`LV_KEY_LEFT` 先交页面钩子,ESC 恒不可拦截,现有 3 页不置开关、零影响 |
| 5 | 边界环绕行为 | ✅ 已确认:都环绕(数字 9↔0,光标首框↔末框) |
**落地要点(与代码一致)**
- **arm API**:`ui_page_password_arm(on_ok)` 由逻辑层在开页前装载完成回调;每次进入重置初始态
(全 `0`、光标首框,与实机一致)。页面模块不感知菜单,比对 `ui_pwd_def.h` 两个编译期密码
(先查修改:两值同设时授高权),一致时回调以授予的权限(`ui_perm_t`)为实参
- **权限注入**:开页 `ui_content_page_open(id, perm)` 携授予权限;描述符 `edit_capable` 声明页面
支持编辑模式;页面钩子经 `ui_content_page_is_editable()`(= edit_capable 且修改权限)决定
只读/可编辑行为
- **菜单路由**:需密码叶子 ENTER → `arm(menu_password_ok)` + 开 `CONTENT_PAGE_PASSWORD`;
密码检查先于内容页判断(带内容页的需密码叶子同样先经密码页);
确认一致时回调(携权限)**先关密码页回菜单屏、再沿未变导航状态续做"进入"动作**(有内容页→
开内容页携权限,否则压层进子框);无密码叶子直接开(默认只读)。
先关页是必须的:不关则后续动作落在被盖住的菜单屏上(焦点已移走,按键全错位);
事件内删对象由 LVGL `e->deleted` 机制保证安全(与 ESC 路径同模式)
- **几何**:标题 y=44;3 个 14×14 数字框、间距 6px、行顶 y=78、首框左 x=53(整组居中);光标框反显
- **回归**:`check_content_pages.py` 含密码页 4 状态(初始 / 输 1 位 / 正确确认进菜单 / 不一致清空),
pwk 终态经像素级几何断言验证为"4 框菜单 + sel=[2,0,0] + 终点空框"
## 五、页面模块(ui_page_xxx)
每页一个 `.c/h` 文件对,只含**数据(Flash)+ 描述符对象**,零 LVGL 对象、零机制代码。
以装置信息页为例(`ui_page_device_info.c` 实际代码节选):
```c
/* 头文件包含区 */
#include "ui_page_device_info.h" /* 内含 ui_content_page.h,并声明描述符对象 */
/* 静态变量定义区 */
static const cp_row_t s_rows[] = {
{"终端类型:", "F30"},
{"终端型号:", "F30"},
{"软件版本:", "SV0.010"},
/* … 共 6 行(参考图 10) */
{"程序日期:", "2024.08.27"},
};
/* 页面描述符(外部链接:框架描述符表 s_defs[] 直接引用) */
const content_page_def_t ui_page_device_info_desc = {
.title = "装置信息",
.subtitle = "馈线自动化终端",
.subtitle_y = 25,
.selectable = 0u,
.top_y = 40,
.pitch = 15,
.band_h = 0,
.text_dy = 0,
.left_x = 6,
.right_x = 86,
.rows_per_page = 0u,
.row_count = 6,
.rows = s_rows,
.fixed_cur_page = 10u,
.fixed_total_pages = 1u,
};
```
```c
/* ui_page_device_info.h(对外只有两个声明) */
#include "ui_content_page.h"
extern const content_page_def_t ui_page_device_info_desc; /* 描述符(Flash const) */
```
- 初始化器按**声明顺序**写指定域(ARMCC v5 = C99 要求顺序一致);省略的钩子字段零初始化为 NULL
- 行表 `s_rows[]` 是模块私有(`static`),只有描述符对象是外部链接
- 动态页的差异:`rows = NULL`,改在描述符里给 `refresh`(逐行取数),行数变化再给 `count_cb`
- 文件编码:`Drivers/BSP/UI/` 下为 **UTF-8 带 BOM**(中文字面量依赖 BOM 让 MSVC 认 UTF-8,
见 AGENTS.md 编码铁律)
## 六、执行上下文与内存
**执行上下文**
- 框架全部回调(绘制 `cp_draw_cb`、键 `cp_key_cb`、4 个页面钩子)运行在 **uiTask 上下文**
(LVGL 刷新与按键都走 `ui_task_func` 主循环,10 ms 周期)
- `refresh` 取到的动态文本在同一刷新周期内经 `lv_draw_label` **同步**光栅化 → 32 B 栈缓冲即安全
- 跨任务数据(ADC/网络/RTC/存储)沿用既有约定:生产者任务写快照(原子/队列),
UI 任务在钩子里读快照;框架与页面模块**不加锁**
**内存(实测,16 KB LVGL 池)**
| 项 | 值 | 说明 |
|---|---|---|
| 框架常驻 RAM | ~20 B | `s_open/s_sel/s_scr/s_cont` 4 变量 |
| 开页峰值 | +776 B | 屏 + 容器 + 事件列表 + 常量样式引用 |
| 关页回落 | 基线 +80 B 一次性残留 | screens 数组高水位 +8、`lv_screen_load` 菜单屏局部 X/Y 样式 +72(LVGL 核心行为,非泄漏;分解见 `菜单页面设计.md` §9.5) |
| 池水位门禁 | 5368 B / 5448 B | 菜单基线 / 开页-关页往返(回归脚本常量,2026-10-11 随 LVGL 控件裁剪由 4784/4864 上移;**与池总大小无关**,16 KB 池下实测值不变) |
| 描述符/行表/标题 | Flash const | 不进 LVGL 池;每页增量 = 描述符 ~40 B + 行表 + 代码 |
| 动态行栈缓冲 | 2×32 B | 仅绘制期存在 |
## 七、新增页面(标准步骤)
| # | 动作 | 改哪里 |
|---|---|---|
| 1 | `content_page_id_t` 加一个枚举值 | `ui_content_page.h` |
| 2 | 新建 `ui_page_xxx.c/h`:行表(或回调)+ `extern const content_page_def_t ui_page_xxx_desc`(头文件声明 + `.c` 定义) | `Drivers/BSP/UI/` |
| 3 | 描述符表加一行 `[新 ID] = &ui_page_xxx_desc` | `ui_content_page.c` `s_defs[]` |
| 4 | 叶子节点 → 页 ID 映射加一个 case | `ui_menu.c` `menu_content_page_for` |
| 5 | 双端注册(显式文件列表,无通配) | `MDK-ARM/*.uvprojx`(Drivers/BSP/UI 组)+ `simulator/CMakeLists.txt`(`UI_SOURCES`) |
| 6 | 回归验证 | `ui-verify` 五门禁;新页在 `check_content_pages.py` 登记黄金参考;含新汉字先重生成字体 |
按页面形态选描述符配置:
| 页面形态 | 配置要点 |
|---|---|
| 静态信息页 | `selectable=0`、`rows`=Flash 表、`fixed_cur_page/fixed_total_pages` 定页脚 |
| 静态列表页 | `selectable=1`、`rows_per_page` 分页、`rows`=Flash 表(钩子全 NULL) |
| 动态数据页 | `rows=NULL` + `refresh`(逐行取数);行数随数据变再加 `count_cb` |
| 表单/操作页 | `selectable=0`、`rows=NULL`、`draw_cb` 自绘内容 + `key_cb` 自有键(ESC 返回仍由框架保证;首个实例=密码页,见 §4.1) |
## 八、验证与回归
- **门禁**(`ui-verify` skill / `UI_自动化测试流程.md`):
0) 模拟器构建 0 警告 → 1) 菜单 8 状态像素回归 + 池基线 → 2) 内容页文本断言 + 66 张黄金参考逐字节比对
(网口设置 4 态 + 密码页 4 态等)+ 开页-关页往返水位 → 3) Keil 构建 0 错 0 警 → 4) 代码规范检查
- **门禁常量**(脚本头部):`MENU_POOL_BASELINE = 5368 B`、`POOL_AFTER_ROUNDTRIP = 5448 B`
- **失败处置**:像素不一致 = 代码 bug(修代码,**禁止**刷参考掩盖);**有意**改视觉 →
先肉眼确认新效果正确,再 `check_content_pages.py --update` 刷参考(`ref/*.bin` 已入库,刷了会进 diff,必须一并提交并说明)
- 新增页面后同步在 `check_content_pages.py` 增加该页的按键序列、文本断言与参考图
## 九、变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-09 | 架构定稿:"框架 + 页面插件"模型,新增页面六步流程 |
| 2026-09-09 | 总览图由 ASCII 框线升级为 Mermaid(结构 flowchart + 生命周期 sequenceDiagram) |
| 2026-09-09 | 拆分执行:`ui_content_page` 提纯为框架(描述符表 + NULL 防护 + 描述符驱动绘制/按键、4 扩展钩子);三页拆出 `ui_page_device_info` / `ui_page_ac` / `ui_page_dc`(extern const 描述符对象);双端注册;五门禁全绿(像素一致、池 4784/4864 B 不变);描述符定稿 extern const 对象模式(ARMCC #144:函数名不能入对象指针表) |
| 2026-09-09 | 按实际代码重写(本版):章节重组为"总览 → 组成 → 框架机制 → 描述符 → 页面模块 → 上下文与内存 → 新增页面 → 验证";去掉规划性页面清单与拆分前执行清单;图扩充为 4 张 Mermaid(系统总览 / 页面生命周期 / 按键分发 / 绘制链);全部内容逐项核对当前代码 |
| 2026-09-09 | 密码页(首个表单/操作页,待实现)设计落档(§4.1):3 位密码、每页专属密码、每页"是否需要密码页"配置、↑↓ 调值 / ←→ 移光标 / ENTER 确认进入;实机视觉参考 `mockups/密码页面实际机器的示例图片.jpg`;同时发现框架冲突:`cp_key_cb` 对 `LV_KEY_LEFT` 先于钩子拦截(ui_content_page.c:345),落地前需描述符拦截标志扩展(ESC 恒不可拦截);4 项待确认(错误行为 / 密码存放 / 环绕行为 / 视觉风格) |
| 2026-09-09 | 密码页设计确认 3 项(§4.1):不一致 = 清空重输;每页专属密码 = 配置页可设 + 存 Flash 配置区;视觉 = 跟随实机照片(由此确定绘制链扩展需求:每页标题 y / "浅 V" 开关);← 键拦截方案与环绕行为仍待确认 |
| 2026-09-09 | 密码页 ← 键冲突解法确认(§4.1):方案 A 描述符拦截开关(`key_intercept`:`LV_KEY_LEFT` 先交页面钩子、ESC 恒不可拦截、现有 3 页零影响);框架扩展清单定稿(拦截标志 + 每页标题 y / "浅 V" 开关),仅剩环绕行为待确认 |
| 2026-09-09 | 密码页设计定稿(§4.1):边界行为 = 都环绕(数字 9↔0、光标首框↔末框);5 项设计全部确认、无遗留待确认——具备实施条件(框架扩展 + 新页面模块 + 每页保护标志/密码) |
| 2026-09-09 | 密码页落地(§4.1):框架描述符新增 title_y/hide_deco/hide_footer/key_intercept + 新 API `ui_content_page_invalidate`;新模块 `ui_page_password`(3 数字框反显光标 + arm API,draw_cb+key_cb);菜单受保护叶子路由(arm→开密码页,确认一致后 `menu_password_ok` **先关页再续做进入动作**);字体 97→102 汉字(198 字形);双端注册;回归扩至 7 页 + 往返水位,五门禁全绿 |
| 2026-09-09 | 权限模型(替代"每页专属密码",§4.1):表行尾注记 ` #P`(需密码,无类别);`ui_pwd_def.h` 两个编译期密码(查看 000 / 修改 123)+ 新 `ui_perm_t` 权限枚举——密码页输查看密码授只读权限、输修改密码授修改权限(只有修改权限进入后才能改参数);生成器同步改造并重新生成(pwd 列 = 0/1 布尔,`ui_menu_tab_has_pwd`);框架 `ui_content_page_open(id, perm)` 携授予权限 + 描述符 `edit_capable` + 新 API `ui_content_page_is_editable`(页面钩子据以判只读/可编辑);路由密码检查提到内容页判断之前(修复带内容页需密码叶子绕过密码的路由缺口);无密码叶子直接进、默认只读。§4.1 功能规则/待确认表/落地要点同步。验证:五门禁全绿(菜单 8 态 + 7 页像素一致、池 4784/4864 B 不变、Keil 0 错 0 警、代码规范 PASS) |
| 2026-09-10 | 网口设置内容页:新增 `Drivers/BSP/NETCFG/`(Flash 持久化 magic 'NCFG'+CRC-16/MODBUS,工厂默认 192.168.1.100/24、gw .1、MAC 全零=芯片内置)+ 页面插件 `ui_page_net_cfg`(5 行动态值 + 可编辑:↑↓调字节 0..255 环绕 / ←→移光标 / ENTER 确认,保存行 ENTER → `net_cfg_save` 下次上电生效);框架新增描述符钩子 `init_cb`(开页重置运行态)+ API `ui_content_page_selected_row`;`net_init` 增 `mac` 参数、netTask 改为等 `SYS_EVT_STORAGE_READY` → `net_cfg_load` → `net_init(ip,mask,gw,mac)` → 回写实际 MAC 影子;模拟器 main 调 `net_cfg_load` 使参考图显示工厂默认值;字体修复:lv_font_conv 丢弃低码位符号(U+FF0C 全角逗号 < 0x4E2D 范围起点 → 页脚逗号长期空白)改由 `merge_extra_symbols()` 补回 + 原本缺字(存/密/户/本/用/钥)→ 111 字 / 208 字形;回归扩至 11 页(网口设置 4 态:可编辑/只读/编辑光标反显/保存 OK),五门禁全绿 |
| 2026-09-10 | 修复网口设置页编辑态渲染 bug:光标段原传整串 `value + start`,而 `lv_draw_label` 按 `coords`(光标盒,仅段宽)折行 → 逐行向盒下方绘制、溢出到其它行与空白区("下方弹出一串字符")。改为只传**段文本**(IP/掩码/网关 3 字符、MAC 2 字符);§3.5 补"`lv_draw_label` 折行陷阱"说明 |
| 2026-09-10 | 网口设置页 MAC 值改常识格式 **`A4:94:B6:12:34:56`**(冒号分隔,17 字符 = 102px);布局调整 名 x=6→4、值 x=60→54(MAC ink 止于 155,仍在白带 2..157 内);编辑光标段偏移 MAC 2i→3i |
| 2026-09-14 | 运行记录/自检记录动态列表页(`ui_page_runlog` / `ui_page_selftest`):描述符新增 `count_cb` + `refresh`(开页/翻页时重读条数与内容,每条 2 行 = 时间戳/描述),数据源为 `App/log/sys_log` 引擎的 `/log/run.log`、`/log/selftest.log`;**框架不变量 selectable ⇒ band_h>0**——band_h=0 时选中行恒黑字无白带 → 黑底不可见(首版两页均踩中,改 band_h=13 对齐 AC/DC 约定);新坑:本 LVGL 版本 `lv_text_get_width` length≠0 时无 NUL 检查,传 -1 越串尾死循环,必须传 strlen(密码页错误提示首版触发,已修并全库排查) |
| 2026-09-15 | 网口设置页保存行改**异步交接**(P1-3 跨任务竞态修复,配置引擎属主 = defaultTask):保存行 ENTER → `net_cfg_save_request()`(置"待保存"标志 + `SYS_EVT_CFG_SAVE` 唤醒属主任务,UI 不再直接触碰 cfg_file 引擎);写盘与"立即应用"由 defaultTask 的 `net_cfg_save_run()` 执行(成功 → OK 并触发 `SYS_EVT_NET_RECONFIG` 免重启重建;失败 → ERR 不触发应用);行值改随交接状态 `net_cfg_save_state()` 显示 ENTER/WAIT/OK/ERR(原为页面局部 `s_saved` 同步结果,删除);无头自检(`--keys`)在注键后同步完成交接保持终态一致;五门禁全绿(像素/文本与改前一致,池水位不变) |
| 2026-10-22 | §八 失败处置口径更新:`ref/*.bin` 黄金参考已入库,刷参考会进 `git diff`、必须随改动一并提交并说明理由(详见 `UI_自动化测试流程.md` §四) |
| 2026-10-27 | 内容页黄金参考 **40 → 47 页**(遥测死区与系数 2 页落地,`simulator/tests/ref/` 实测 47 张:7 张新增、存量 40 张逐字节零漂移);本文件的当前态计数同步改 47,历史行 27 → 40 原样保留 |
| 2026-10-27 | 内容页黄金参考 **47 → 53 页**(通讯设置页落地并绑 `#page=comm`;判据 = `simulator/tests/ref/` 入库 `shot_*.bin` 实数 53 张,`git diff --name-status 31ca4a1f HEAD -- simulator/tests/ref` 实测只有 6 个 `A`、零 `M` ⇒ 存量 47 张逐字节零漂移);本文件的当前态计数已同步 |
| 2026-10-26 | 验证与回归段的页数口径修正为 **27 页**黄金参考(`simulator/tests/ref/` 入库 27 个 `shot_*.bin`,含整定 9 态);上方变更记录里的「11 页」是当时事实不动 |
| 2026-10-26 | 门禁常量修正为 5368 B / 5448 B(= 两个回归脚本头部现值);本文件正文表早已是 5368/5448,只这一处残留 2026-10-11 裁剪 LVGL 控件类之前的 4784/4864 |
| 2026-10-26 | 验证与回归段的黄金参考页数 **27 → 40 页**(UI 三页落地后 `simulator/tests/ref/` 实数 40 张);上方变更记录里的 11 / 27 是各当时下限,保留 |