diff --git a/.gitignore b/.gitignore index 438c193c..570ec49d 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,9 @@ simulator/SDL2/ # Binary / large files *.bin +# 内容页黄金参考截图(回归测试基准,须入库) +!simulator/tests/ref/*.bin *.pyc + +# Python venv (unified env, see AGENTS.md "Python 环境"; rebuild via requirements.txt) +.venv/ diff --git a/AGENTS.md b/AGENTS.md index 2fb4959d..728c5d4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,11 @@ ## Python 环境(跑脚本用) -- PATH 默认 `python` 是 msys64 的(`C:\msys64\ucrt64\bin\python.exe`,无 pip/fonttools),**不要用它**跑依赖第三方库的脚本 -- 本机已验证可用解释器:conda base 环境 `C:\ProgramData\anaconda3\python.exe`(Python 3.12.4,fontTools 4.51.0 预装) -- 字体生成需 fontTools:`& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py`;`extract_cn.py` / `tools/menu_gen/gen_menu.py` 纯标准库,任意 python3 均可 +- **统一入口:仓库内 `.venv\Scripts\python.exe`**(Python 3.12.4,fontTools + Pillow 已装),所有依赖第三方库的脚本一律用它;依赖清单 `requirements.txt`(仓库根目录) +- 重建/换机:`& 'C:\ProgramData\anaconda3\python.exe' -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`(种子解释器可用任意带 venv 的 python ≥3.10;`.venv/` 已 gitignore,不入库) +- `requirements.txt` 必须**纯 ASCII**:Windows 下 pip 按系统代码页(GBK)解码该文件,中文注释会 UnicodeDecodeError(2026-09-09 实测) +- PATH 默认 `python` 是 msys64 的(`C:\msys64\ucrt64\bin\python.exe`),**不要用它**跑依赖第三方库的脚本;纯标准库脚本(回归测试、`extract_cn.py`、`gen_menu.py`)任意 python3 均可 +- 字体生成:`& '.venv\Scripts\python.exe' tools/font_gen/make_font.py` ## 硬件 diff --git a/Drivers/BSP/UI/ui_content_page.c b/Drivers/BSP/UI/ui_content_page.c new file mode 100644 index 00000000..9838f999 --- /dev/null +++ b/Drivers/BSP/UI/ui_content_page.c @@ -0,0 +1,497 @@ +/* + * 模块名称:UI Content Page (内容页面显示层) + * 模块功能:菜单叶子节点的内容页(装置信息/交流量/直流量)——独立 LVGL 屏幕, + * 全屏容器 DRAW_MAIN 自绘全部内容(浅 V 装饰 + 居中标题 + 行数据 + + * 页脚页码),无子对象;进入时切屏并获焦、ESC 返回菜单屏并还原焦点 + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-09-09 + * 修改记录: + * 2026-09-09 王建锋 创建:装置信息/交流量/直流量三页(参考图 10/20/21 实测几何) + */ + +/* 头文件包含区 */ +#include + +#include "ui_content_page.h" + +#include "ui_menu_view.h" + +#include "lvgl.h" + +#define DBG_TAG "[UI_CONTENT_PAGE]" +#include "dbg_log.h" + +/* 私有宏定义区 */ + +/* + * 布局常量(设备像素;参考图 10/20/21 实测,3x 放大图缩回 160x160 测量): + * 标题 y=2(CJK 字形顶距 +1 → 墨迹自 y=3 起,与参考图一致)、页脚 y=143; + * 信息页行:首行 y=40、行距 15、标签列 x=6 / 值列 x=86(无选中带); + * 列表页行:槽顶 = 24+14i、选中白带 x=2..156 高 13、名 x=7 / 值 x=101; + * 字形顶距(CJK +1 / ASCII +2)已计入放置 y,墨迹与参考图对齐 + */ +#define CP_SCREEN_W 160 /* 屏幕宽(px,与 ui_port.h / 模拟器一致) */ +#define CP_SCREEN_H 160 /* 屏幕高(px) */ + +#define CP_TITLE_Y 2 /* 标题放置 y(居中;墨迹自 y=3 起) */ +#define CP_PAGE_LINE_Y 143 /* 页脚"第 X 页,共 Y 页"放置 y(x=6 左对齐) */ + +#define CP_DECO_LEG_W 3 /* "浅 V"两腿线宽(与菜单根框一致:1bpp 斜线须加粗) */ +#define CP_DECO_HORIZ_W 2 /* "浅 V"水平线宽 */ + +/* 私有类型定义区 */ + +/* 行数据:左列名 + 右列值(Flash 字面量,不 strdup) */ +typedef struct { + const char *name; + const char *value; +} cp_row_t; + +/* + * 页面定义(Flash 常量表):标题/行几何/数据表/页脚参数; + * selectable=1 为可移选分页的数据列表页,0 为静态信息页 + */ +typedef struct { + const char *title; /* 标题(浅 V 区居中) */ + uint8_t selectable; /* 1 = 数据列表页(↑↓移选/分页);0 = 信息页(静态行) */ + 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 total_rows; /* 数据总行数 */ + const cp_row_t *rows; /* 数据表(Flash) */ + const char *subtitle; /* 副标题(居中;NULL = 无,仅信息页有) */ + int32_t subtitle_y; /* 副标题放置 y(-1 = 无) */ + uint8_t fixed_cur_page; /* 页脚"第 X 页"固定显示值(信息页按参考图原样) */ + uint8_t fixed_total_pages; /* 页脚"共 Y 页"固定显示值 */ +} cp_def_t; + +/* 静态变量定义区 */ + +/* "浅 V"装饰点集与线宽:几何与菜单根框一致(ui_menu_view.c s_deco_*),culling 区同范围 */ +static const lv_point_precise_t s_deco_leg_l[2] = { {7, 7}, {16, 17} }; /* 左腿 */ +static const lv_point_precise_t s_deco_hoz[2] = { {16, 17}, {145, 17} }; /* 水平线:接两腿底端 */ +static const lv_point_precise_t s_deco_leg_r[2] = { {158, 7}, {145, 17} }; /* 右腿 */ +static const lv_point_precise_t *const s_deco_segs[3] = {s_deco_leg_l, s_deco_leg_r, s_deco_hoz}; +static const int32_t s_deco_seg_w[3] = {CP_DECO_LEG_W, CP_DECO_LEG_W, CP_DECO_HORIZ_W}; + +static const lv_area_t s_deco_band = {0, 0, CP_SCREEN_W - 1, 19}; /* 浅 V culling 区 */ + +/* 装置信息行(参考图 10;标签含全角冒号,值列固定 x=86) */ +static const cp_row_t s_info_rows[] = { + {"终端类型:", "F30"}, + {"终端型号:", "F30"}, + {"软件版本:", "SV0.010"}, + {"硬件版本:", "HW0.010"}, + {"软件校验:", "4454"}, + {"程序日期:", "2024.08.27"}, +}; + +/* 交流量行(参考图 20 = 第 1 页原样;第 2/3 页为占位数据,可替换) */ +static const cp_row_t s_ac_rows[] = { + {"Ia1", "0.000"}, /* 第 1 页(参考图 20) */ + {"Ib1", "0.137"}, + {"Ic1", "0.274"}, + {"Ia2", "0.411"}, + {"Ib2", "0.548"}, + {"Ic2", "0.685"}, + {"Ua1", "0.82"}, + {"Ub1", "0.96"}, + {"Uc1", "1.03"}, /* 第 2 页(占位数据) */ + {"Uab", "1.17"}, + {"Ubc", "1.24"}, + {"Uca", "1.31"}, + {"Hz", "50.00"}, + {"P", "1.42"}, + {"Q", "0.87"}, + {"S", "1.63"}, + {"PF", "0.95"}, /* 第 3 页(占位数据) */ + {"Iavg","0.35"}, + {"Uavg","0.87"}, + {"Fmax","50.02"}, + {"Imax","1.24"}, + {"Pmax","2.10"}, + {"Qmax","1.05"}, + {"Smax","2.45"}, +}; + +/* 直流量行(参考图 21) */ +static const cp_row_t s_dc_rows[] = { + {"ZL01", "3.01"}, + {"ZL02", "3.15"}, + {"Pa1", "3.288"}, +}; + +/* 页面定义表(顺序与 content_page_id_t 一致) */ +static const cp_def_t s_defs[CONTENT_PAGE_COUNT] = { + /* 装置信息:静态键值行 + 副标题;页脚按参考图原样"第 10 页,共 1 页" */ + {"装置信息", 0u, 40, 15, 0, 0, 6, 86, 0u, (uint16_t)6, s_info_rows, + "馈线自动化终端", 25, 10u, 1u}, + /* 交流量:24 项 / 8 项每页(3 页);text_dy=0:ASCII 字形顶距 +2,墨迹落槽顶+2(与参考图一致) */ + {"交流量", 1u, 24, 14, 13, 0, 7, 101, 8u, (uint16_t)24, s_ac_rows, + NULL, -1, 0u, 0u}, + /* 直流量:3 项单页 */ + {"直流量", 1u, 24, 14, 13, 0, 7, 101, 8u, (uint16_t)3, s_dc_rows, + NULL, -1, 0u, 0u}, +}; + +static content_page_id_t s_open = CONTENT_PAGE_COUNT; /* 当前打开的页(COUNT = 未打开) */ +static uint16_t s_sel; /* 列表页选中行(全局下标,跨页连续) */ +static lv_obj_t *s_scr; /* 页面屏幕(进入时建、返回时毁) */ +static lv_obj_t *s_cont; /* 页面全屏容器(焦点/键入口 + 自绘载体) */ + +/* flat:透明、无边框/圆角/内边距——剥离 mono 主题给普通 lv_obj 附加的 card 样式(同 ui_menu_view.c) */ +/* 用常量样式(LV_STYLE_CONST_INIT):属性存静态数组,零 LV_MEM 池分配; + * 动态样式首次 set_prop 会从池分配 values_and_props 且永不释放(x64 实测 88B/个) */ +static const lv_style_const_prop_t cp_flat_props[] = { + { .prop = LV_STYLE_BG_OPA, .value = { .num = LV_OPA_TRANSP } }, + { .prop = LV_STYLE_BORDER_WIDTH, .value = { .num = 0 } }, + { .prop = LV_STYLE_RADIUS, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_LEFT, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_RIGHT, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_TOP, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_BOTTOM, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_ROW, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_COLUMN, .value = { .num = 0 } }, + LV_STYLE_CONST_PROPS_END +}; + +LV_STYLE_CONST_INIT(cp_style_flat, cp_flat_props); + +/* 函数定义区 */ + +/* + * 函数功能:判断两区域是否相交(等价 lv_area_intersect 非空判断;避免依赖 LVGL 私有头) + * 入口参数:a - 区域 1 const lv_area_t * + * b - 区域 2 const lv_area_t * + * 返回值:true - 相交;false - 不相交 + * 限定条件:无 + * 函数说明:四边取 min/max 成交集,任一边为空即不相交(与 ui_menu_view.c 同实现) + */ +static bool cp_areas_intersect(const lv_area_t *a, const lv_area_t *b) { + int32_t x1 = (a->x1 > b->x1) ? a->x1 : b->x1; + int32_t y1 = (a->y1 > b->y1) ? a->y1 : b->y1; + int32_t x2 = (a->x2 < b->x2) ? a->x2 : b->x2; + int32_t y2 = (a->y2 < b->y2) ? a->y2 : b->y2; + + return (x1 <= x2 && y1 <= y2); +} + +/* + * 函数功能:绘制居中单行文本(标题/副标题) + * 入口参数:layer - 绘制层 lv_layer_t * + * text - 文本(Flash) const char * + * y - 放置 y int32_t + * 返回值:无 + * 限定条件:text 非空;重绘区已含该行区域(调用方 culling) + * 函数说明:x = (屏宽 - 文本实际步进)/2 居中;白色、静态字符串不拷内存 + */ +static void cp_draw_centered_text(lv_layer_t *layer, const char *text, int32_t y) { + lv_area_t area; + lv_draw_label_dsc_t ld; + + area.x1 = 0; + area.x2 = CP_SCREEN_W - 1; + area.y1 = y; + area.y2 = y + 15; /* 覆盖字体行高(13)+ 余量 */ + + lv_draw_label_dsc_init(&ld); + ld.base.layer = layer; + ld.text = text; + ld.text_static = 1; /* Flash 字符串:不拷内存 */ + ld.color = lv_color_white(); + /* 本 LVGL 版 lv_text_get_width 的 length=0 表示"不测量",必须传 strlen */ + ld.ofs_x = (CP_SCREEN_W - lv_text_get_width(text, (uint32_t)strlen(text), LV_FONT_DEFAULT, 0)) / 2; + ld.ofs_y = 0; + lv_draw_label(layer, &ld, &area); +} + +/* + * 函数功能:页面自绘(浅 V 装饰 + 标题/副标题 + 行数据 + 页脚页码) + * 入口参数:e - 事件指针 lv_event_t * + * 返回值:无 + * 限定条件:事件为 LV_EVENT_DRAW_MAIN,页面已打开 + * 函数说明: 1. 浅 V 装饰几何与菜单根框一致(3 段线逐段画) + * 2. 列表页窗口 = 选中行所在页(首可见行 = (sel/每页数)*每页数), + * 行槽位置固定、数据随页切换;信息页全量静态绘制 + * 3. 选中行整行白带(x=2..156)+ 黑字,非选中白字 + * 4. 各区块按 culling 区跳过不在重绘区的部分 + */ +static void cp_draw_cb(lv_event_t *e) { + const cp_def_t *d = &s_defs[s_open]; + lv_layer_t *layer = lv_event_get_layer(e); + + /* "浅 V"装饰:3 段线(与菜单根框同几何) */ + if (cp_areas_intersect(&s_deco_band, &layer->_clip_area)) { + for (uint8_t i = 0; i < 3; i++) { + lv_draw_line_dsc_t ld; + + lv_draw_line_dsc_init(&ld); + ld.base.layer = layer; + ld.color = lv_color_white(); + ld.width = s_deco_seg_w[i]; + ld.p1 = s_deco_segs[i][0]; + ld.p2 = s_deco_segs[i][1]; + lv_draw_line(layer, &ld); + } + } + + /* 标题(居中) */ + { + lv_area_t t; + + t.x1 = 0; + t.x2 = CP_SCREEN_W - 1; + t.y1 = CP_TITLE_Y; + t.y2 = CP_TITLE_Y + 15; + if (cp_areas_intersect(&t, &layer->_clip_area)) { + cp_draw_centered_text(layer, d->title, CP_TITLE_Y); + } + } + + /* 副标题(仅信息页) */ + if (d->subtitle != NULL) { + lv_area_t t; + + t.x1 = 0; + t.x2 = CP_SCREEN_W - 1; + t.y1 = d->subtitle_y; + t.y2 = d->subtitle_y + 15; + if (cp_areas_intersect(&t, &layer->_clip_area)) { + cp_draw_centered_text(layer, d->subtitle, d->subtitle_y); + } + } + + /* 行数据:列表页窗口跟随选中行所在页,信息页全量 */ + int32_t first = 0; + int32_t cnt = (int32_t)d->total_rows; + + if (d->selectable && d->rows_per_page > 0u) { + first = (int32_t)(s_sel / d->rows_per_page) * d->rows_per_page; /* 窗口顶行 */ + cnt = (int32_t)d->total_rows - first; + if (cnt > (int32_t)d->rows_per_page) { + cnt = (int32_t)d->rows_per_page; /* 末页不足整页时截断 */ + } + } + + for (int32_t i = 0; i < cnt; i++) { + int32_t row_idx = first + i; /* 数据下标(跨页连续) */ + int32_t slot_y = d->top_y + i * d->pitch; /* 行槽顶(窗口内位置固定) */ + bool is_sel = (d->selectable != 0u) && (row_idx == (int32_t)s_sel); + lv_color_t color = is_sel ? lv_color_black() : lv_color_white(); + lv_area_t strip; + + /* 行条带(整宽)不在重绘区:跳过(culling) */ + strip.x1 = 0; + strip.x2 = CP_SCREEN_W - 1; + strip.y1 = slot_y; + strip.y2 = slot_y + d->pitch - 1; + if (!cp_areas_intersect(&strip, &layer->_clip_area)) { + continue; + } + + /* 选中行:整行白带(x=2..156,高 band_h) */ + if (is_sel && d->band_h > 0) { + lv_draw_rect_dsc_t fd; + lv_area_t band; + + band.x1 = 2; + band.x2 = CP_SCREEN_W - 3; /* x=156(与参考图实测一致) */ + band.y1 = slot_y; + band.y2 = slot_y + d->band_h - 1; + lv_draw_rect_dsc_init(&fd); + fd.base.layer = layer; + fd.bg_color = lv_color_white(); + fd.bg_opa = LV_OPA_COVER; + lv_draw_rect(layer, &fd, &band); + } + + /* 左列(标签/名)与右列(值):固定列 x,垂直偏移 text_dy */ + { + lv_draw_label_dsc_t ld; + + /* ofs_x/ofs_y 相对 coords 原点(strip.y1=slot_y),勿再叠加 slot_y */ + lv_draw_label_dsc_init(&ld); + ld.base.layer = layer; + ld.text = d->rows[row_idx].name; + ld.text_static = 1; /* Flash 字符串:不拷内存 */ + ld.color = color; /* 选中黑 / 非选中白 */ + ld.ofs_x = d->left_x; + ld.ofs_y = d->text_dy; + lv_draw_label(layer, &ld, &strip); + + lv_draw_label_dsc_init(&ld); + ld.base.layer = layer; + ld.text = d->rows[row_idx].value; + ld.text_static = 1; + ld.color = color; + ld.ofs_x = d->right_x; + ld.ofs_y = d->text_dy; + lv_draw_label(layer, &ld, &strip); + } + } + + /* 页脚"第 X 页,共 Y 页":列表页随选中行现算,信息页按参考图固定值 */ + { + char buf[32]; + uint8_t cur; + uint8_t total; + lv_area_t t; + + if (d->selectable) { + cur = (uint8_t)(s_sel / d->rows_per_page + 1u); + total = (uint8_t)((d->total_rows + d->rows_per_page - 1u) / d->rows_per_page); + } + else { + cur = d->fixed_cur_page; + total = d->fixed_total_pages; + } + + t.x1 = 0; + t.x2 = CP_SCREEN_W - 1; + t.y1 = CP_PAGE_LINE_Y; + t.y2 = CP_PAGE_LINE_Y + 15; + if (cp_areas_intersect(&t, &layer->_clip_area)) { + /* lv_snprintf 而非 snprintf:MSVC 对 printf 族做格式串扫描,中文字面量会误报 C4819 */ + lv_snprintf(buf, sizeof(buf), "第 %u 页,共 %u 页", (unsigned)cur, (unsigned)total); + + lv_draw_label_dsc_t ld; + + /* ofs 相对 coords 原点(t.y1=CP_PAGE_LINE_Y),垂直偏移取 0 */ + lv_draw_label_dsc_init(&ld); + ld.base.layer = layer; + ld.text = buf; /* 栈缓冲:本页码每次现算 */ + ld.color = lv_color_white(); + ld.ofs_x = 6; + ld.ofs_y = 0; + lv_draw_label(layer, &ld, &t); + } + } +} + +/* + * 函数功能:页面按键事件入口(↑↓移选 / ESC·←返回) + * 入口参数:e - 事件指针 lv_event_t * + * 返回值:无 + * 限定条件:容器已加入默认输入组并获焦 + * 函数说明:1. ↑/↓:列表页移选(首尾环绕,跨页连续),窗口随页切换后重绘;信息页不响应 + * 2. ESC/←:关闭页面返回菜单(导航状态未压层,原样还原) + */ +static void cp_key_cb(lv_event_t *e) { + const cp_def_t *d = &s_defs[s_open]; + lv_key_t key = (lv_key_t)lv_event_get_key(e); + + switch (key) { + case LV_KEY_UP: + if (d->selectable && d->total_rows > 0u) { + /* 上移、首尾环绕:先加 total 再减 1 保证无符号下不为负 */ + s_sel = (uint16_t)((s_sel + d->total_rows - 1u) % d->total_rows); + lv_obj_invalidate(s_cont); /* 重绘(选中带/窗口/页码可能变化) */ + } + break; + case LV_KEY_DOWN: + if (d->selectable && d->total_rows > 0u) { + s_sel = (uint16_t)((s_sel + 1u) % d->total_rows); /* 下移、越尾绕回首行 */ + lv_obj_invalidate(s_cont); + } + break; + case LV_KEY_ESC: + case LV_KEY_LEFT: + ui_content_page_close(); /* 返回菜单屏(焦点/导航状态还原) */ + break; + default: + break; /* 其余键值忽略 */ + } +} + +/* + * 函数功能:打开指定内容页(建屏 + 切屏 + 焦点置页面容器) + * 入口参数:id - 内容页 ID content_page_id_t + * 返回值:true - 成功;false - 失败(ID 无效/已有页面打开/对象创建失败) + * 限定条件:ui_menu_init() 已完成(默认输入组与菜单屏存在) + * 函数说明:1. lv_obj_create(NULL) 新建独立屏幕 + 全屏容器(自绘载体,无子对象) + * 2. 容器入默认输入组并获焦后再切屏——按键立即由本模块接管 + * 3. 导航状态不压层:菜单 s_depth/s_sel 原样保留,ESC 返回即还原 + */ +bool ui_content_page_open(content_page_id_t id) { + if (id >= CONTENT_PAGE_COUNT || s_open != CONTENT_PAGE_COUNT) { + return false; /* ID 无效或已有页面打开(防御) */ + } + + s_scr = lv_obj_create(NULL); /* parent=NULL → 新建屏幕 */ + if (s_scr == NULL) { + return false; /* LVGL 池耗尽 */ + } + + s_cont = lv_obj_create(s_scr); + if (s_cont == NULL) { + lv_obj_delete(s_scr); /* 回滚:屏已建、容器失败 */ + s_scr = NULL; + return false; + } + lv_obj_add_flag(s_cont, LV_OBJ_FLAG_IGNORE_LAYOUT); /* 绝对定位,不参与父级布局 */ + lv_obj_set_size(s_cont, CP_SCREEN_W, CP_SCREEN_H); /* 全屏覆盖 */ + lv_obj_add_style(s_cont, &cp_style_flat, 0); /* 透明、无边框:剥离主题 card */ + lv_obj_add_flag(s_cont, LV_OBJ_FLAG_CLICK_FOCUSABLE); /* 可获焦:键事件入口 */ + lv_obj_add_event_cb(s_cont, cp_key_cb, LV_EVENT_KEY, NULL); + lv_obj_add_event_cb(s_cont, cp_draw_cb, LV_EVENT_DRAW_MAIN, NULL); + + s_open = id; + s_sel = 0u; /* 每次进入从首行开始(页面按需建毁,不跨次保留) */ + + /* 焦点/键入口:入默认输入组并获焦(先于切屏,保证按键立即由本模块接管) */ + lv_group_t *g = lv_group_get_default(); + if (g != NULL) { + lv_group_add_obj(g, s_cont); + lv_group_focus_obj(s_cont); + } + + lv_screen_load(s_scr); /* 切到内容页(菜单屏对象保留、不销毁) */ + DBG_INFO("page open: id=%u rows=%u", (unsigned)id, (unsigned)s_defs[id].total_rows); + return true; +} + +/* + * 函数功能:关闭当前内容页(焦点回菜单容器 + 切回菜单屏 + 销毁页面) + * 入口参数:无 + * 返回值:无 + * 限定条件:ui_content_page_is_open() 为 true + * 函数说明:1. 先移焦到菜单容器再删屏——防输入组焦点悬空(删除自动移出组) + * 2. 切回菜单屏后销毁页面屏幕,LVGL 池内存随即回落 + */ +void ui_content_page_close(void) { + if (s_open == CONTENT_PAGE_COUNT || s_cont == NULL) { + return; /* 未打开(防御) */ + } + + lv_obj_t *menu_cont = ui_menu_view_container(); + if (menu_cont != NULL) { + lv_group_focus_obj(menu_cont); /* 焦点先回菜单容器 */ + lv_screen_load(lv_obj_get_screen(menu_cont)); /* 切回菜单屏(对象未动,原样还原) */ + } + + DBG_INFO("page close: id=%u", (unsigned)s_open); + + lv_obj_delete(s_cont); /* 先删容器 */ + lv_obj_delete(s_scr); /* 再删屏 */ + + s_scr = NULL; + s_cont = NULL; + s_open = CONTENT_PAGE_COUNT; + s_sel = 0u; +} + +/* + * 函数功能:查询是否有内容页打开 + * 入口参数:无 + * 返回值:true - 有页面打开;false - 无 + * 限定条件:无 + * 函数说明:供逻辑层/调试判断当前处于菜单屏还是内容页 + */ +bool ui_content_page_is_open(void) { + return (s_open != CONTENT_PAGE_COUNT); +} diff --git a/Drivers/BSP/UI/ui_content_page.h b/Drivers/BSP/UI/ui_content_page.h new file mode 100644 index 00000000..3e1ad85c --- /dev/null +++ b/Drivers/BSP/UI/ui_content_page.h @@ -0,0 +1,59 @@ +/* + * 模块名称:UI Content Page (内容页面显示层) + * 模块功能:菜单叶子节点的内容页(装置信息/交流量/直流量)——独立 LVGL 屏幕, + * 全屏容器自绘全部内容(浅 V 装饰 + 居中标题 + 行数据 + 页脚页码), + * 无子对象;进入时切屏并获焦、ESC 返回菜单屏并还原焦点; + * 不持有导航状态——由逻辑层 ui_menu.c 在 ENTER 叶子节点时驱动 + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-09-09 + * 修改记录: + * 2026-09-09 王建锋 创建:装置信息/交流量/直流量三页(参考图 10/20/21) + */ +#ifndef UI_CONTENT_PAGE_H +#define UI_CONTENT_PAGE_H + +#include +#include + +/* + * 内容页 ID:与菜单叶子节点一一对应(映射见 ui_menu.c menu_content_page_for); + * CONTENT_PAGE_COUNT 兼作"无内容页/未打开"哨兵值 + */ +typedef enum { + CONTENT_PAGE_DEVICE_INFO = 0u, /* 装置信息(node 1):静态键值行 + 副标题,单页 */ + CONTENT_PAGE_AC, /* 交流量(node 3):数据列表,24 项 / 8 项每页 */ + CONTENT_PAGE_DC, /* 直流量(node 4):数据列表,3 项单页 */ + CONTENT_PAGE_COUNT /* 哨兵:无效 ID / 未打开 */ +} content_page_id_t; + +/* + * 函数功能:打开指定内容页(建屏 + 切屏 + 焦点置页面容器) + * 入口参数:id - 内容页 ID content_page_id_t + * 返回值:true - 成功;false - 失败(ID 无效/已有页面打开/对象创建失败) + * 限定条件:ui_menu_init() 已完成(默认输入组与菜单屏存在) + * 函数说明:1. 新建独立屏幕,全屏容器自绘全部内容(无子对象,池开销极小) + * 2. 容器入默认输入组并获焦——按键由本模块处理(↑↓移选/ESC返回) + * 3. 导航状态不压层:菜单 s_depth/s_sel 原样保留,ESC 返回即还原 + */ +bool ui_content_page_open(content_page_id_t id); + +/* + * 函数功能:关闭当前内容页(焦点回菜单容器 + 切回菜单屏 + 销毁页面) + * 入口参数:无 + * 返回值:无 + * 限定条件:ui_content_page_is_open() 为 true + * 函数说明:先移焦再删屏,防输入组焦点悬空;LVGL 池内存随即回落 + */ +void ui_content_page_close(void); + +/* + * 函数功能:查询是否有内容页打开 + * 入口参数:无 + * 返回值:true - 有页面打开;false - 无 + * 限定条件:无 + * 函数说明:供逻辑层/调试判断当前处于菜单屏还是内容页 + */ +bool ui_content_page_is_open(void); + +#endif /* UI_CONTENT_PAGE_H */ diff --git a/Drivers/BSP/UI/ui_font_cn_12.c b/Drivers/BSP/UI/ui_font_cn_12.c index a5b1ed30..2d6cd1c5 100644 --- a/Drivers/BSP/UI/ui_font_cn_12.c +++ b/Drivers/BSP/UI/ui_font_cn_12.c @@ -1,7 +1,7 @@ /******************************************************************************* * Size: 12 px * Bpp: 1 - * Opts: --font C:\Users\ADMINI~1\AppData\Local\Temp\simsun_regular.ttf --size 12 --bpp 1 -r 0x20-0x7E --symbols ▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面: --no-compress --format lvgl --lv-font-name ui_font_cn_12 -o D:\Code\DTU Code\STM32F4-Base\Drivers\BSP\UI\ui_font_cn_12.c + * Opts: --font C:\Users\ADMINI~1\AppData\Local\Temp\simsun_regular.ttf --size 12 --bpp 1 -r 0x20-0x7E --symbols ▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面:馈线化终端号件版硬校验程序日期第页共, --no-compress --format lvgl --lv-font-name ui_font_cn_12 -o D:\Code\DTU Code\STM32F4-Base\Drivers\BSP\UI\ui_font_cn_12.c ******************************************************************************/ #ifdef LV_LVGL_H_INCLUDE_SIMPLE @@ -327,6 +327,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x14, 0x50, 0x50, 0x4, 0x1, 0x40, 0xc6, 0x60, 0x30, + /* U+4EF6 "件" */ + 0x21, 0x5, 0x21, 0x24, 0x27, 0xfd, 0x10, 0x82, + 0x10, 0x42, 0xff, 0x41, 0x8, 0x21, 0x4, 0x20, + 0x80, + /* U+4F5C "作" */ 0x14, 0x2, 0x80, 0x9f, 0x95, 0x6, 0x21, 0x47, 0x88, 0x81, 0x10, 0x23, 0xe4, 0x40, 0x88, 0x11, @@ -347,6 +352,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x92, 0x12, 0x7e, 0x48, 0x49, 0xf9, 0x21, 0x2f, 0xf0, + /* U+5171 "共" */ + 0x11, 0x2, 0x20, 0x44, 0x3f, 0xe1, 0x10, 0x22, + 0x4, 0x47, 0xff, 0x0, 0x2, 0x20, 0x82, 0x20, + 0x20, + /* U+5236 "制" */ 0x10, 0x2a, 0x5, 0xf4, 0xc8, 0x9f, 0xf2, 0x22, 0x5f, 0x4a, 0xa9, 0x55, 0x2a, 0x85, 0x70, 0x88, @@ -362,6 +372,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x50, 0x4a, 0x89, 0x89, 0x3f, 0x44, 0x8, 0x82, 0x60, + /* U+5316 "化" */ + 0x12, 0x2, 0x40, 0x88, 0x91, 0x26, 0x29, 0x46, + 0x8, 0x81, 0x30, 0x2a, 0x4, 0x44, 0x88, 0x90, + 0xf0, + /* U+533A "区" */ 0xff, 0xe0, 0xa, 0xa, 0x44, 0x8a, 0x21, 0x8, 0xa2, 0x44, 0xa0, 0xa0, 0xf, 0xfc, @@ -383,6 +398,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0xff, 0xc0, 0x60, 0x30, 0x18, 0xc, 0x6, 0x3, 0x1, 0xff, 0xc0, 0x40, + /* U+53F7 "号" */ + 0x3f, 0x84, 0x10, 0x82, 0x1f, 0xc0, 0x1, 0xff, + 0xc4, 0x1, 0x0, 0x3f, 0xc0, 0x8, 0x1, 0x0, + 0xc0, + /* U+542F "启" */ 0x4, 0x0, 0x40, 0xff, 0x90, 0x12, 0x2, 0x7f, 0xc8, 0x1, 0xff, 0x30, 0x2a, 0x5, 0x7f, 0xc8, @@ -418,6 +438,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x4, 0x87, 0xff, 0x4, 0x1, 0x30, 0xc1, 0x60, 0x10, + /* U+5E8F "序" */ + 0x2, 0xf, 0xfd, 0x0, 0x2f, 0xe4, 0x8, 0x9a, + 0x10, 0x83, 0xff, 0x42, 0x28, 0x49, 0x8, 0x43, + 0x0, + /* U+5F0F "式" */ 0x1, 0x40, 0x24, 0x4, 0x7f, 0xf0, 0x10, 0x2, 0x1f, 0x40, 0x88, 0x10, 0x82, 0x14, 0x79, 0xf0, @@ -501,11 +526,20 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x7f, 0xc0, 0x80, 0x10, 0x2, 0xf, 0xfe, 0x14, 0x2, 0x80, 0x90, 0x22, 0x28, 0x46, 0x7, 0x80, + /* U+65E5 "日" */ + 0xff, 0xc0, 0x60, 0x30, 0x1f, 0xfc, 0x6, 0x3, + 0x1, 0x80, 0xff, 0xe0, 0x20, + /* U+65F6 "时" */ 0x0, 0x5e, 0xa, 0x41, 0x4b, 0xf9, 0x5, 0xe0, 0xa4, 0x94, 0x8a, 0x91, 0x5e, 0xa, 0x41, 0x0, 0xe0, + /* U+671F "期" */ + 0x48, 0x9, 0x3f, 0xf4, 0xa4, 0x97, 0x9e, 0x92, + 0x5e, 0x4a, 0x4f, 0xfd, 0x20, 0x25, 0x24, 0xc3, + 0x30, + /* U+677F "板" */ 0x20, 0x64, 0xf0, 0x90, 0x7a, 0x2, 0x7e, 0xea, 0x5b, 0x4d, 0x2a, 0x25, 0x44, 0x90, 0xa5, 0x19, @@ -516,6 +550,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x8, 0x21, 0xfc, 0x20, 0x87, 0xf0, 0x0, 0x7f, 0xf0, + /* U+6821 "校" */ + 0x22, 0x4, 0x20, 0xbf, 0xf8, 0x2, 0x28, 0xe8, + 0x9a, 0xad, 0x14, 0x22, 0x84, 0x20, 0x8a, 0x16, + 0x30, + /* U+68C0 "检" */ 0x21, 0x4, 0x20, 0x8a, 0x7a, 0x22, 0x82, 0xcf, 0x9c, 0x5, 0x12, 0x29, 0x44, 0xa8, 0x92, 0x17, @@ -555,6 +594,11 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x90, 0x12, 0x2, 0x7f, 0xc0, 0x1, 0x49, 0x44, 0x90, + /* U+7248 "版" */ + 0x10, 0x6a, 0xf1, 0x50, 0x2a, 0x7, 0xfe, 0x8a, + 0x51, 0x4b, 0xaa, 0x55, 0x4a, 0x91, 0x65, 0x49, + 0x10, + /* U+754C "界" */ 0x7f, 0xc8, 0x89, 0xff, 0x22, 0x27, 0xfc, 0x8, 0x6, 0xc7, 0x7, 0x11, 0x2, 0x20, 0x84, 0x20, @@ -569,6 +613,25 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0xc8, 0x13, 0xfe, 0xa0, 0x47, 0xf8, 0x81, 0x1f, 0xe0, + /* U+786C "硬" */ + 0xf7, 0xe4, 0x20, 0x9f, 0x22, 0xa7, 0x7d, 0xaa, + 0x95, 0xf2, 0xc8, 0x55, 0xe, 0x61, 0x53, 0x80, + + /* U+7A0B "程" */ + 0x17, 0xdc, 0x88, 0x91, 0x13, 0xef, 0x0, 0x4f, + 0x9c, 0x43, 0x48, 0xa7, 0xc4, 0x20, 0x84, 0x17, + 0xf0, + + /* U+7AEF "端" */ + 0x81, 0x9, 0x24, 0x24, 0xf7, 0xf0, 0x1, 0x5f, + 0xe8, 0x45, 0x7f, 0x4a, 0xa5, 0x57, 0x2a, 0x84, + 0x30, + + /* U+7B2C "第" */ + 0x42, 0xf, 0x7e, 0x52, 0x0, 0x7, 0xfc, 0x8, + 0x9f, 0xf2, 0x20, 0x7f, 0xe2, 0x84, 0x90, 0xe2, + 0x60, + /* U+7C7B "类" */ 0x4, 0x4, 0x90, 0x54, 0x7f, 0xf1, 0x50, 0x49, 0x10, 0x10, 0x20, 0xff, 0xe1, 0x40, 0xc6, 0x60, @@ -579,6 +642,16 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0x6, 0x13, 0xff, 0x4, 0x24, 0x91, 0x11, 0x46, 0x10, + /* U+7EBF "线" */ + 0x21, 0x44, 0x25, 0x7, 0xcb, 0x8e, 0x10, 0x43, + 0xd1, 0xc7, 0x8a, 0x1, 0x86, 0x27, 0xa, 0x86, + 0x30, + + /* U+7EC8 "终" */ + 0x22, 0x4, 0x79, 0x11, 0x4d, 0x4e, 0x10, 0x45, + 0x13, 0x1f, 0x18, 0x0, 0x86, 0xc3, 0x6, 0x0, + 0x20, + /* U+7EF4 "维" */ 0x22, 0x84, 0x49, 0x1f, 0xca, 0x4e, 0xc8, 0x4f, 0xd1, 0x27, 0xa4, 0x7, 0xe6, 0x93, 0x12, 0x3, @@ -677,6 +750,24 @@ static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { 0xff, 0xe0, 0x80, 0x20, 0x3f, 0xf4, 0x92, 0x9e, 0x52, 0x4a, 0x79, 0x49, 0x2f, 0xfd, 0x0, 0x80, + /* U+9875 "页" */ + 0xff, 0xe0, 0x80, 0x20, 0x3f, 0xe4, 0x4, 0x80, + 0x91, 0x12, 0x22, 0x44, 0x41, 0x60, 0xc3, 0x60, + 0x10, + + /* U+9988 "馈" */ + 0x41, 0x9, 0xfd, 0xa4, 0xd7, 0xf0, 0x10, 0xbf, + 0xd0, 0x2, 0x7f, 0x49, 0x29, 0x25, 0x8b, 0x26, + 0x10, + + /* U+9A8C "验" */ + 0x1, 0x1c, 0x20, 0x8a, 0x52, 0x2a, 0x83, 0x4f, + 0xbc, 0x0, 0x92, 0x39, 0x5a, 0xa8, 0x52, 0x37, + 0xf0, + + /* U+FF0C "," */ + 0xf6, + /* U+FF1A ":" */ 0xf0, 0xf0, @@ -797,14 +888,14 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { {.bitmap_index = 621, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 638, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 655, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 672, .adv_w = 192, .box_w = 10, .box_h = 11, .ofs_x = 1, .ofs_y = -2}, - {.bitmap_index = 686, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 702, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 719, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 735, .adv_w = 192, .box_w = 9, .box_h = 10, .ofs_x = 1, .ofs_y = -1}, - {.bitmap_index = 747, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 764, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 781, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 672, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 689, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 706, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 723, .adv_w = 192, .box_w = 10, .box_h = 11, .ofs_x = 1, .ofs_y = -2}, + {.bitmap_index = 737, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 753, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 770, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 786, .adv_w = 192, .box_w = 9, .box_h = 10, .ofs_x = 1, .ofs_y = -1}, {.bitmap_index = 798, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 815, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 832, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, @@ -812,12 +903,12 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { {.bitmap_index = 866, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 883, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 900, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 917, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, - {.bitmap_index = 931, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 948, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 965, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 982, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 999, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 917, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 934, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 951, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 968, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 985, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1002, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, {.bitmap_index = 1016, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 1033, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 1050, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, @@ -825,45 +916,64 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { {.bitmap_index = 1084, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 1101, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, {.bitmap_index = 1118, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1135, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1151, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1168, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1185, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1202, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1219, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1236, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1252, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1269, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1286, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1303, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1320, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1337, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1354, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, - {.bitmap_index = 1369, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1386, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1403, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1420, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1437, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1454, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1471, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, - {.bitmap_index = 1485, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1502, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1519, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1536, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1553, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1570, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1587, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1604, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1621, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1638, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1655, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1672, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1689, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1706, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1723, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1740, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, - {.bitmap_index = 1756, .adv_w = 192, .box_w = 2, .box_h = 6, .ofs_x = 2, .ofs_y = 0}, - {.bitmap_index = 1758, .adv_w = 165, .box_w = 8, .box_h = 10, .ofs_x = 1, .ofs_y = -1} /* U+25B6 */ + {.bitmap_index = 1135, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1152, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1169, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1186, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1203, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1220, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1236, .adv_w = 192, .box_w = 9, .box_h = 11, .ofs_x = 1, .ofs_y = -2}, + {.bitmap_index = 1249, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1266, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1283, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1300, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1317, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1334, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1351, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1368, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1384, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1401, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1418, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1435, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1452, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1469, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1486, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1503, .adv_w = 192, .box_w = 10, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, + {.bitmap_index = 1518, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1535, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1551, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1568, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1585, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1602, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1619, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1636, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1653, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1670, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1687, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1704, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1721, .adv_w = 192, .box_w = 9, .box_h = 12, .ofs_x = 1, .ofs_y = -2}, + {.bitmap_index = 1735, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1752, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1769, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1786, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1803, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1820, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1837, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1854, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1871, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1888, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1905, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1922, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1939, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1956, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1973, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 1990, .adv_w = 192, .box_w = 11, .box_h = 11, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 2006, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 2023, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 2040, .adv_w = 192, .box_w = 11, .box_h = 12, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 2057, .adv_w = 192, .box_w = 2, .box_h = 4, .ofs_x = 2, .ofs_y = 0}, + {.bitmap_index = 2058, .adv_w = 192, .box_w = 2, .box_h = 6, .ofs_x = 2, .ofs_y = 0}, + {.bitmap_index = 2060, .adv_w = 165, .box_w = 8, .box_h = 10, .ofs_x = 1, .ofs_y = -1} /* U+25B6 */ }; /*--------------------- @@ -871,16 +981,18 @@ static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { *--------------------*/ static const uint16_t unicode_list_1[] = { - 0x0, 0x32, 0x82, 0x9b, 0x153, 0x1d4, 0x1d8, 0x233, - 0x42d, 0x444, 0x49f, 0x531, 0x582, 0x5b9, 0x5c3, 0x5da, - 0x626, 0x641, 0x982, 0xb04, 0xd4e, 0xd91, 0xd95, 0x1106, - 0x1131, 0x1149, 0x114a, 0x114c, 0x1266, 0x1442, 0x148d, 0x149b, - 0x149c, 0x1565, 0x159e, 0x16c4, 0x173c, 0x1767, 0x176b, 0x17d7, - 0x17ed, 0x1976, 0x19dc, 0x1ab7, 0x1c18, 0x1d72, 0x1ed9, 0x1f38, - 0x1f42, 0x1ffc, 0x22b0, 0x2743, 0x28eb, 0x2902, 0x2e72, 0x2ef2, - 0x30eb, 0x3148, 0x3165, 0x33e1, 0x3a43, 0x3abc, 0x3d5d, 0x3da6, - 0x3da7, 0x3db5, 0x3dd9, 0x4166, 0x41c7, 0x4211, 0x425c, 0x43c6, - 0x4696, 0x4829, 0x485b, 0x4959, 0xb111 + 0x0, 0x32, 0x82, 0x9b, 0xed, 0x153, 0x1d4, 0x1d8, + 0x233, 0x368, 0x42d, 0x444, 0x49f, 0x50d, 0x531, 0x582, + 0x5b9, 0x5c3, 0x5da, 0x5ee, 0x626, 0x641, 0x982, 0xb04, + 0xd4e, 0xd91, 0xd95, 0x1086, 0x1106, 0x1131, 0x1149, 0x114a, + 0x114c, 0x1266, 0x1442, 0x148d, 0x149b, 0x149c, 0x1565, 0x159e, + 0x16c4, 0x173c, 0x1767, 0x176b, 0x17d7, 0x17dc, 0x17ed, 0x1916, + 0x1976, 0x19dc, 0x1a18, 0x1ab7, 0x1c18, 0x1d72, 0x1ed9, 0x1f38, + 0x1f42, 0x1ffc, 0x22b0, 0x243f, 0x2743, 0x28eb, 0x2902, 0x2a63, + 0x2c02, 0x2ce6, 0x2d23, 0x2e72, 0x2ef2, 0x30b6, 0x30bf, 0x30eb, + 0x3148, 0x3165, 0x33e1, 0x3a43, 0x3abc, 0x3d5d, 0x3da6, 0x3da7, + 0x3db5, 0x3dd9, 0x4166, 0x41c7, 0x4211, 0x425c, 0x43c6, 0x4696, + 0x4829, 0x485b, 0x4959, 0x4a6c, 0x4b7f, 0x4c83, 0xb103, 0xb111 }; /*Collect the unicode lists and glyph_id offsets*/ @@ -892,10 +1004,10 @@ static const lv_font_fmt_txt_cmap_t cmaps[] = }, { .range_start = 19977, .range_length = 45330, .glyph_id_start = 96, - .unicode_list = unicode_list_1, .glyph_id_ofs_list = NULL, .list_length = 77, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY + .unicode_list = unicode_list_1, .glyph_id_ofs_list = NULL, .list_length = 96, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY }, { - .range_start = 9654, .range_length = 1, .glyph_id_start = 173, + .range_start = 9654, .range_length = 1, .glyph_id_start = 192, .unicode_list = NULL, .glyph_id_ofs_list = NULL, .list_length = 0, .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY } }; diff --git a/Drivers/BSP/UI/ui_menu.c b/Drivers/BSP/UI/ui_menu.c index 87abd878..c4290e2d 100644 --- a/Drivers/BSP/UI/ui_menu.c +++ b/Drivers/BSP/UI/ui_menu.c @@ -21,6 +21,8 @@ #include "ui_menu_view.h" +#include "ui_content_page.h" + #include "ui_menu_tab.h" #include "lvgl.h" @@ -88,6 +90,23 @@ static void menu_back(void) { ui_menu_view_focus(); } +/* + * 函数功能:查叶子节点对应的内容页(无内容页返回哨兵值) + * 入口参数:node - 菜单节点下标 uint8_t + * 返回值:内容页 ID content_page_id_t;无内容页 = CONTENT_PAGE_COUNT + * 限定条件:无 + * 函数说明:仅部分叶子有内容页(参考图 10/20/21);下标对应 ui_menu_tab.c + * node 1=装置信息、3=交流量、4=直流量(表序变更时须同步本映射) + */ +static content_page_id_t menu_content_page_for(uint8_t node) { + switch (node) { + case 1u: return CONTENT_PAGE_DEVICE_INFO; /* 装置信息(L1 叶子) */ + case 3u: return CONTENT_PAGE_AC; /* 交流量(实时数据子项) */ + case 4u: return CONTENT_PAGE_DC; /* 直流量(实时数据子项) */ + default: return (content_page_id_t)CONTENT_PAGE_COUNT; /* 无内容页 */ + } +} + /* * 函数功能:按键语义分发(移/进/退) * 入口参数:depth - 焦点所在层深度 uint8_t @@ -95,7 +114,7 @@ static void menu_back(void) { * 返回值:无 * 限定条件:ui_menu_init() 已完成 * 函数说明: 1. UP/DOWN:移选(首尾环绕);选中越出可见区由显示层跟随滚动 - * 2. ENTER/RIGHT:进子框(叶子 → 空白终点框) + * 2. ENTER/RIGHT:叶子有内容页 → 切内容屏(不压层);否则进子框(叶子 → 空白终点框) * 3. ESC/LEFT:关最右框(根框不响应) * 4. 空框(终点页 0 条目)只响应返回 */ @@ -119,12 +138,21 @@ static void menu_key_handle(uint8_t depth, lv_key_t key) { } break; case LV_KEY_ENTER: - case LV_KEY_RIGHT: + case LV_KEY_RIGHT: { /* 进子框:空框无条目可进;最深层(depth=MAX-1)无子层可再开 */ - if (cnt > 0u && depth < MENU_PAGE_MAX - 1u) { - menu_open(kids[s_sel[depth]]); /* 压层:建新框 + 焦点移到新框 */ + if (cnt > 0u) { + content_page_id_t pid = menu_content_page_for(kids[s_sel[depth]]); + + if (pid < CONTENT_PAGE_COUNT) { + /* 叶子有内容页:切内容屏(不压层,导航状态原样保留;ESC 返回即还原) */ + ui_content_page_open(pid); + } + else if (depth < MENU_PAGE_MAX - 1u) { + menu_open(kids[s_sel[depth]]); /* 压层:建新框 + 焦点移到新框(叶子 → 空白终点框) */ + } } break; + } case LV_KEY_ESC: case LV_KEY_LEFT: if (depth > 0u) { /* 根框(d=0)不响应返回 */ diff --git a/Drivers/BSP/UI/ui_menu_view.c b/Drivers/BSP/UI/ui_menu_view.c index 80d1fb98..1d294467 100644 --- a/Drivers/BSP/UI/ui_menu_view.c +++ b/Drivers/BSP/UI/ui_menu_view.c @@ -78,7 +78,22 @@ static uint8_t s_row_node[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行数据:节 static menu_frame_t s_frame[MENU_PAGE_MAX]; /* 每层框几何缓存:位置/尺寸/选中行(现算不落状态,供子框对齐;见上方类型注释) */ /* 样式(静态,一次性初始化,常驻) */ -static lv_style_t s_style_flat; /* 通用:透明、无边框/圆角/内边距(剥离主题 card) */ +/* flat:通用——透明、无边框/圆角/内边距(剥离主题 card)。用常量样式(LV_STYLE_CONST_INIT): + * 属性存静态数组、零 LV_MEM 池分配;动态样式首次 set_prop 会从池分配 values_and_props 且永不释放 */ +static const lv_style_const_prop_t s_flat_props[] = { + { .prop = LV_STYLE_BG_OPA, .value = { .num = LV_OPA_TRANSP } }, + { .prop = LV_STYLE_BORDER_WIDTH, .value = { .num = 0 } }, + { .prop = LV_STYLE_RADIUS, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_LEFT, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_RIGHT, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_TOP, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_BOTTOM, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_ROW, .value = { .num = 0 } }, + { .prop = LV_STYLE_PAD_COLUMN, .value = { .num = 0 } }, + LV_STYLE_CONST_PROPS_END +}; + +LV_STYLE_CONST_INIT(s_style_flat, s_flat_props); static lv_style_t s_style_frame; /* 框体:透明底 + 1px 白边框 */ static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */ @@ -89,7 +104,7 @@ static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */ * 入口参数:无 * 返回值:无 * 限定条件:无 - * 函数说明: 1. flat 剥离主题给对象附加的卡片样式(边框/圆角/内边距) + * 函数说明: 1. flat 剥离主题给对象附加的卡片样式(边框/圆角/内边距;常量样式,见上方定义) * 2. frame 为所有级联框(含根框)的 1px 白边框 * 3. 选中行反色不用样式:自绘回调按 sel 直接画白带(行不是对象) */ @@ -102,14 +117,6 @@ static void view_styles_init(void) { } inited = true; - /* flat:透明、无边框/圆角/内边距(剥离主题附加的卡片样式) */ - lv_style_init(&s_style_flat); - lv_style_set_bg_opa(&s_style_flat, LV_OPA_TRANSP); - lv_style_set_border_width(&s_style_flat, 0); - lv_style_set_radius(&s_style_flat, 0); - lv_style_set_pad_all(&s_style_flat, 0); - lv_style_set_pad_gap(&s_style_flat, 0); - /* frame:透明底 + 1px 白边框(所有级联框含根框) */ lv_style_init(&s_style_frame); lv_style_set_bg_opa(&s_style_frame, LV_OPA_TRANSP); diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 584ed70a..c448dcc6 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -3123,6 +3123,11 @@ 1 ..\Drivers\BSP\UI\ui_menu_view.c + + ui_content_page.c + 1 + ..\Drivers\BSP\UI\ui_content_page.c + ui_menu_tab.c 1 diff --git a/README.md b/README.md index a3d23746..2914856b 100644 --- a/README.md +++ b/README.md @@ -152,24 +152,28 @@ STM32F4-Base/ │ ├── ftp_capture.py # FTP 流量抓取(主机端) │ └── time_sync_server.py # 时间同步推送服务(主机端) ├── docs/ # 项目文档 +│ ├── UI 设计/ # LVGL UI:架构 / 菜单页面 / 制作流程 / 字体 / 模拟器 / 自动化测试 + mockups 样机 │ ├── 代码规范/ # C 代码规范、pre-commit / cppcheck 使用说明 │ ├── 问题记录/ # 已知陷阱与解决方案 │ ├── 测试说明/ # 测试指南 │ ├── 芯片手册/ # 硬件数据手册 │ ├── 测试开发标准流程.md # 测试开发标准流程 │ ├── 测试文档模板规范.md # 测试文档模板规范 -│ └── *.md # 存储架构 / UI 规划 / 墙钟时间 / 启动顺序 / LVGL 模拟器 +│ └── *.md # 存储架构 / 墙钟时间 / 启动顺序等(UI 相关文档在 UI 设计/) ├── scripts/ # pre-commit 本地钩子脚本 │ ├── check_coding_standard.py # 代码规范机械检查 │ └── check_mojibake.py # U+FFFD 乱码检查 -├── simulator/ # LVGL PC 模拟器(CMake + SDL2) +├── simulator/ # LVGL PC 模拟器(CMake + SDL2,与 MCU 共用 UI 源码) │ ├── CMakeLists.txt # 模拟器构建脚本 │ ├── main.c / sim_port.c/h # 模拟器入口与移植层 │ ├── lv_conf.h # LVGL 配置(1bpp + SDL) +│ ├── i1_to_png.py # 截图 I1(1bpp) → PNG 转换工具 +│ ├── tests/ # 无头回归脚本 + 黄金参考(菜单像素回归 / 内容页黄金参考) │ └── SDL2/ # SDL2 2.32.10 预编译库 ├── tools/ # 开发工具 │ └── font_gen/ # 中文字体生成工具 ├── ui_cn_chars.txt # 中文字体字符集 +├── requirements.txt # Python 依赖清单(统一 .venv 环境,见 AGENTS.md「Python 环境」) ├── STM32F407-Demo.ioc # CubeMX 工程文件 ├── .pre-commit-config.yaml # pre-commit 钩子配置 ├── .gitlint # commit message 检查规则 @@ -183,7 +187,7 @@ STM32F4-Base/ ### 环境要求 - **Keil MDK-ARM v5** (ARMCC 编译器) -- **Python 3**(测试脚本) +- **Python 3**(统一入口为仓库内 `.venv`,依赖见 `requirements.txt`,重建方法见 AGENTS.md「Python 环境」) - **ST-Link** 或兼容调试器 ### 编译 @@ -208,7 +212,7 @@ MDK-ARM\download.bat ### LVGL PC 模拟器 -PC 端 UI 开发调试(无需烧录 MCU),详见 `docs/LVGL模拟器部署与使用说明.md`: +PC 端 UI 开发调试(无需烧录 MCU,与 MCU 共用 `Drivers/BSP/UI/` 源码),详见 `docs/UI 设计/LVGL模拟器部署与使用说明.md`: ```powershell cd simulator @@ -217,6 +221,15 @@ cmake --build build --config Debug # 运行 simulator\build\Debug\lvgl_sim.exe ``` +UI 改动回归门禁(菜单像素回归 + 内容页黄金参考 + 池水位校验): + +```powershell +.venv\Scripts\python.exe simulator/tests/check_menu_rows.py +.venv\Scripts\python.exe simulator/tests/check_content_pages.py +``` + +详见 `docs/UI 设计/UI_自动化测试流程.md`。 + ## 驱动开发指南 ### CH395F 以太网 @@ -359,9 +372,12 @@ pre-commit run --all-files | `docs/STM32F4-Base存储架构说明.md` | 三层存储软件栈详解(FatFS → dhara FTL → NAND) | | `docs/项目启动顺序管理.md` | Event Flags 就绪屏障设计 | | `docs/系统墙钟时间维护说明.md` | RTC + 墙钟时间子系统 | -| `docs/UI_图形界面实施规划.md` | LVGL UI 实施规划 | -| `docs/UI_中文字体生成与配置.md` | 中文字体生成与配置 | -| `docs/LVGL模拟器部署与使用说明.md` | LVGL PC 模拟器部署 | +| `docs/UI 设计/UI_设计架构.md` | LVGL UI 系统架构(菜单 / 内容页 / 内存预算) | +| `docs/UI 设计/菜单页面设计.md` | 菜单导航行为、数据流与内容页设计 | +| `docs/UI 设计/UI_界面制作流程.md` | 参考图 → HTML 样机 → LVGL 代码管线(P0~P5 门禁) | +| `docs/UI 设计/UI_中文字体生成与配置.md` | 中文字体生成与配置 | +| `docs/UI 设计/LVGL模拟器部署与使用说明.md` | LVGL PC 模拟器部署 | +| `docs/UI 设计/UI_自动化测试流程.md` | UI 回归门禁(脚本 + 池水位常量 + 黄金参考更新) | | `docs/代码规范/嵌入式C语言代码规范(V1.0).md` | C 语言代码规范 | | `docs/代码规范/pre-commit使用说明.md` | 自动化代码审查部署与使用 | | `docs/代码规范/cppcheck使用说明.md` | cppcheck 规则、配置与误报抑制 | diff --git a/docs/UI 设计/UI_中文字体生成与配置.md b/docs/UI 设计/UI_中文字体生成与配置.md index 7542bf83..68ea886b 100644 --- a/docs/UI 设计/UI_中文字体生成与配置.md +++ b/docs/UI 设计/UI_中文字体生成与配置.md @@ -27,13 +27,14 @@ 安装完成后验证可用:`lv_font_conv -h` 能打印用法即成功。 -> **本机 Python 解释器**:PATH 默认 `python` 是 msys64 的(无 pip/fonttools),直接跑 `make_font.py` 会失败。用 conda base 环境(已预装 fontTools): +> **Python 解释器(统一入口)**:用仓库内 `.venv`(已装 fontTools,见 AGENTS.md「Python 环境」): > > ```powershell -> & 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py +> & '.venv\Scripts\python.exe' tools/font_gen/make_font.py > ``` > -> `extract_cn.py` 纯标准库,任意 python3 均可。换机器时若该解释器缺 fonttools,用其自带包管理器安装(如 `& '...\python.exe' -m pip install fonttools`)。 +> PATH 默认 `python` 是 msys64 的(无 fonttools),直接跑会失败。`.venv` 缺失/换机时重建:`& 'C:\ProgramData\anaconda3\python.exe' -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`。 +> `extract_cn.py` 纯标准库,任意 python3 均可。 --- @@ -54,7 +55,7 @@ scanned 13 files; unique non-ASCII chars: 78 -> D:\Code\DTU Code\STM32F4-Base\ui_cn_chars.txt ``` -产物 `ui_cn_chars.txt` 为本工程实际显示的汉字清单(当前 78 字),可留作字符集记录。 +产物 `ui_cn_chars.txt` 为本工程实际显示的汉字清单(当前 97 字),可留作字符集记录。 --- @@ -62,21 +63,21 @@ scanned 13 files; unique non-ASCII chars: 78 已落库脚本 `tools/font_gen/make_font.py`:自动定位 `lv_font_conv`(全局安装或 PATH)、自动处理宋体 `.ttc` 的 face0 抽取,直接生成 `Drivers/BSP/UI/ui_font_cn_12.c`。 -**前置依赖(仅需一次):** +**前置依赖(仅需一次,统一装进仓库 `.venv`):** ```bash npm i -g lv_font_conv # TTF/OTF -> LVGL 字体 离线转换器 -pip install fonttools # 仅当使用 .ttc 集合字体(如宋体)时需要 +.venv\Scripts\python.exe -m pip install fonttools # 仅当使用 .ttc 集合字体(如宋体)时需要;已含于 requirements.txt ``` > 若 `npm i -g lv_font_conv` 报代理错误,先清代理:`npm config delete proxy` / `npm config delete https-proxy`。 -**执行命令(在仓库根目录,用 §2.1 的 conda base 解释器):** +**执行命令(在仓库根目录,用 §2.1 的 `.venv` 解释器):** ```powershell -& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py # 默认:宋体, 12px, 1bpp -& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py --bpp 2 # 宋体细笔画发虚时改用 2bpp -& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py --font C:/path/to/font.ttf # 换其他字体 +& '.venv\Scripts\python.exe' tools/font_gen/make_font.py # 默认:宋体, 12px, 1bpp +& '.venv\Scripts\python.exe' tools/font_gen/make_font.py --bpp 2 # 宋体细笔画发虚时改用 2bpp +& '.venv\Scripts\python.exe' tools/font_gen/make_font.py --font C:/path/to/font.ttf # 换其他字体 ``` 输出示例: diff --git a/docs/UI 设计/UI_界面制作流程.md b/docs/UI 设计/UI_界面制作流程.md index 71a6c614..880c888c 100644 --- a/docs/UI 设计/UI_界面制作流程.md +++ b/docs/UI 设计/UI_界面制作流程.md @@ -2,7 +2,7 @@ > **用途**:规范"参考图 → HTML 样机 → LVGL 代码 → 双端验证"的完整管线,规定每一步的**动作、产物、放行条件(gate)**,保证过程可重复、可交接。 > **适用**:所有 UI 新界面 / 界面大改;小改动走 §11 快速通道。 -> **配套文档**:`UI_设计架构.md`(系统如何工作)、`菜单页面设计.md`(菜单/导航行为与数据流设计)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)。 +> **配套文档**:`UI_设计架构.md`(系统如何工作)、`菜单页面设计.md`(菜单/导航行为与数据流设计)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)、`UI_自动化测试流程.md`(P3/P4 回归门禁的可执行版本:脚本 + 池水位常量 + 黄金参考更新)。 > **首个实例**:界面 01 主界面(根页),样机 `mockups/01_主界面.html`(本文所有示例数字均取自该试点)。 --- @@ -48,7 +48,7 @@ flowchart TD |---|---|---|---| | C1 | 屏幕 | 160×160,1bpp 单色(`LV_COLOR_FORMAT_I1`),无物理屏(远端客户端显示) | `ui_port.h` | | C2 | 字体 | **唯一**:`ui_font_cn_12`(宋体 12px 1bpp)。汉字 12px/字,ASCII 6px/字,行高 13px。需要其他字号 = 大工程(新字体 + 双端 `lv_conf.h`),默认禁止 | `ui_font_cn_12.c` | -| C3 | 字符集 | 子集字体(当前 78 汉字 + ASCII 0x20-0x7E);**缺字显示空白**;新字必须重生成字体 | `tools/font_gen/` | +| C3 | 字符集 | 子集字体(当前 97 汉字 + ASCII 0x20-0x7E,共 193 字形);**缺字显示空白**;新字必须重生成字体 | `tools/font_gen/` | | C4 | LVGL 内存池 | 40KB(`LV_MEM_SIZE`);任一时刻只放"当前导航路径 + 当前内容页" | `Lib/lv_conf.h` | | C5 | 文本 | 一律静态字符串(`lv_label_set_text_static`),禁止动态字符串/strdup | 架构文档 | | C6 | 编码 | `Drivers/BSP/UI/*`、`ui_task.c`、`Lib/lv_conf.h` 为 **UTF-8 带 BOM**;改动只用 Edit 工具,**禁止** PowerShell/cmd 整文件重写(U+FFFD 不可逆) | AGENTS.md 铁律 | @@ -214,10 +214,10 @@ flowchart TD ``` 1. 代码落库(新界面字符串已写入 ui_menu.c 等 UI 源码) 2. python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt(自动含新字;纯标准库,任意 python3) -3. & 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py # 宋体 12px 1bpp + ASCII + 字符集 → ui_font_cn_12.c(全量重生成 + 自动补回宋体缺失的 ▶ 箭头,幂等;需 fontTools) +3. & '.venv\Scripts\python.exe' tools/font_gen/make_font.py # 宋体 12px 1bpp + ASCII + 字符集 → ui_font_cn_12.c(全量重生成 + 自动补回宋体缺失的 ▶ 箭头,幂等;需 fontTools) 4. 生成物 diff 检查:新增字在位、旧字不变、Opts 行字符集与预期一致 ``` -- 一次性环境准备:`npm i -g lv_font_conv`;fonttools 用 conda base 解释器装(本机 `C:\ProgramData\anaconda3\python.exe` 已预装 fontTools,PATH 默认 python 是 msys64 的、无 pip/fonttools,勿用它跑 make_font.py) +- 一次性环境准备:`npm i -g lv_font_conv`;fonttools/pillow 统一装进仓库 `.venv`(`.venv\Scripts\python.exe -m pip install -r requirements.txt`,见 AGENTS.md「Python 环境」)。PATH 默认 python 是 msys64 的、无第三方库,勿用它跑 make_font.py - `ui_cn_chars.txt`(仓库根,受 git 跟踪)与 `ui_font_cn_12.c` **随代码一起提交** - 双端 `LV_FONT_DEFAULT` 均指向 `&ui_font_cn_12` → 重生成后两端自动生效 - P1 已知的新增字若含生僻字(宋体可能无此字形)→ 在写码前先手动验证字形,避免"代码写完才发现是空白" @@ -248,6 +248,9 @@ flowchart TD (样机渲染图由 P1 的 `tools/ui_mock/render_mockup.py` 产出,即本屏"输入图片") 4. **不合适 → 依 HTML 样机改 LVGL 代码**(HTML 为设计基准)→ 重构建 → 重新出图 → 再比对,循环至一致 5. 交互行为(导航/滚动/选中)在窗口模式 `.\build\Debug\lvgl_sim.exe` 内按键验证(离线出图只覆盖首帧静态画面) +6. **自动化回归**(任何 UI 改动必跑,脚本已入库无需重写):仓库根目录执行 + `python simulator/tests/check_menu_rows.py` + `python simulator/tests/check_content_pages.py`, + 全部门禁与常量说明见 `UI_自动化测试流程.md` ### 6.2 比对检查单 | 项 | 通过标准 | @@ -266,6 +269,7 @@ flowchart TD ### 6.4 Gate(进入 P4) - [ ] 模拟器出图 `shot.png` 与样机渲染图 `render_NN.png` 逐项同构(§6.2,忽略 6.3) - [ ] 导航行为符合键位语义表(架构 §3.4) +- [ ] 自动化回归 PASS:`check_menu_rows.py` + `check_content_pages.py`(`UI_自动化测试流程.md`) --- @@ -339,6 +343,8 @@ flowchart TD | 关闭了 `LV_DRAW_SW_COMPLEX`(省代码) | `lv_line` 斜线不绘制(如主界面顶部"浅 V"两腿消失,只剩水平线) | `LV_DRAW_SW_COMPLEX` 两端显式 =1;`LV_DRAW_SW_CIRCLE_CACHE_SIZE` 须非零(默认 4,否则零长数组 C2466)(架构 §关键约束 7) | | 斜线用细 `line_width`(如 2px) | 1bpp 阈值吃掉斜线边缘,腿偏细、比设计稿细 | 斜线加粗(腿 3px),或按段拆 `lv_line` 分粗细(如"浅 V"两腿 3 / 水平线 2) | | 样机用了花哨 CSS | P2 翻译不出来 | 1bpp 禁区(C8),P1 阶段就拦下 | +| UI 源文件(UTF-8 BOM)里 `snprintf` 格式串含中文 | 模拟器 cl.exe 报 C4819(代码页 936 误读 UTF-8 字节) | 格式化输出用 `lv_snprintf`(LVGL 自带,双端一致),勿用标准库 `snprintf` | +| 常驻样式用动态 `lv_style_t` + set_prop | LVGL 池永久占用:首次 set_prop 分配 `values_and_props` 且永不释放(x64 实测 88 B/个) | 常驻样式一律常量样式:`LV_STYLE_CONST_INIT(var, props[])` + `lv_style_const_prop_t[]`(属性存 Flash、零池分配,见架构 §5.6) | --- @@ -433,3 +439,4 @@ P5 [ ] UI_设计架构.md 变更记录更新 | 2026-09-03 | §一 流程总览重画为清晰的纵向管线图:每阶段标注 产物 + Gate,P1/P3 的"比对→修正"迭代环显式标出(⟲);补全原先遗漏的 P1 原图校验环/`NN_说明.md` 产物、P2 双端注册、P3 导航 Gate,与各阶段细则(§3~§8)对齐 | | 2026-09-04 | §九 新增 9.1“样机投入分流(按屏型)”:明确 HTML 样机=静态外观轴(设计先行、非代码生成器),设计文档 + 模拟器=行为/数据轴;静态内容页走完整样机管线、导航/交互重屏 HTML 只做静态首帧、行为靠 `菜单页面设计.md`;配套文档补列 `菜单页面设计.md` | | 2026-09-07 | §一 五阶段管线图由 ASCII 转 Mermaid flowchart:P1/P3“比对→修正”迭代环由行内 ⟲ 注记改为显式判定节点 + 回边(未收敛不得前进更直观);各 Gate 条件与产物标注保留、语义不变 | +| 2026-09-09 | 内容页三屏(装置信息/交流量/直流量,`ui_content_page`)落地后回填:C3 字符集 78→97 汉字(193 字形);§十 新增两坑——UI BOM 源文件 `snprintf` 中文格式串触发 C4819(改 `lv_snprintf`)、常驻动态样式池永久占用(改 `LV_STYLE_CONST_INIT` 常量样式,x64 实测省 88 B/个) | diff --git a/docs/UI 设计/UI_自动化测试流程.md b/docs/UI 设计/UI_自动化测试流程.md new file mode 100644 index 00000000..a57e848e --- /dev/null +++ b/docs/UI 设计/UI_自动化测试流程.md @@ -0,0 +1,78 @@ +# UI 自动化测试流程(模拟器回归) + +> **用途**:所有涉及 UI 的改动(菜单结构 / 内容页 / 字体 / 样式 / 几何)改完代码后,按本文跑一遍全部门禁再提交。脚本已入库,直接调用即可,无需临时重写。 +> **定位**:`UI_界面制作流程.md` P3/P4 门禁的可执行版本;设计期样机比对(P1/P2)仍走该文档与 `tools/ui_mock/`。 + +--- + +## 一、前置条件 + +| 项 | 说明 | +|---|---| +| 模拟器已构建 | 仓库根目录:`cmake --build simulator/build --config Debug`(首次需先 `cd simulator; cmake -B build -G "Visual Studio 16 2019" -A x64`,见 `simulator/README.md` §3.2) | +| Python | 统一入口 `.venv\Scripts\python.exe`(AGENTS.md「Python 环境」);两个回归脚本纯标准库,任意 python3 均可,为保持一致一律用 .venv | + +## 二、流程总览 + +```powershell +# 仓库根目录执行(python 统一用 .venv,见 AGENTS.md「Python 环境」) +cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警告) +.venv\Scripts\python.exe simulator/tests/check_menu_rows.py # 1) 菜单 8 状态像素回归 + 池基线门禁 +.venv\Scripts\python.exe simulator/tests/check_content_pages.py # 2) 内容页黄金参考回归 + 往返池门禁 +& "MDK-ARM\build.bat" # 3) Keil 构建(BUILD_OK,0 错 0 警) +.venv\Scripts\python.exe scripts/check_coding_standard.py # 4) 代码规范机械检查(PASS) +``` + +| 步 | 脚本/命令 | 门禁内容 | 预期输出 | +|---|---|---|---| +| 1 | `simulator/tests/check_menu_rows.py` | 菜单 8 状态:选中行白带 / 非选行黑底白字 / 框体 1px 边框 / 往返像素一致(S3==S1、S6==S1、S7==S2)+ S0 池基线 | `PASS: 8 状态全部断言通过(8 图,池基线 4784 B)` | +| 2 | `simulator/tests/check_content_pages.py` | 装置信息/交流量/直流量三页截图与 `ref/` 黄金参考**逐字节**比对 + 开页-关页往返池水位 | `PASS: 3 内容页 + 往返池水位全部通过` | +| 3 | `MDK-ARM\build.bat` | MCU 端编译(UI 源码双端共用,改一端必须过另一端) | 退出码 0 = BUILD_OK | +| 4 | `scripts/check_coding_standard.py` | 代码规范 V1.0 机械检查 | PASS | + +**退出码约定**:两个脚本均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失、参考缺失)。 +失败时脚本打印差异明细(ink runs 对照 / 像素差数),内容页脚本保留现场截图于临时目录并提示路径。 + +## 三、内存水位门禁常量 + +| 常量 | 所在脚本 | 当前值 | 含义 | +|---|---|---|---| +| `MENU_POOL_BASELINE` | check_menu_rows.py | **4784 B** | 菜单根框建完的 LVGL 池 used(x64 模拟器) | +| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | **4864 B** | 开页-关页往返后水位 = 基线 + 一次性残留 80 B | + +**漂移处理**:菜单/页面结构或样式变更导致门禁失败时,先确认新值合理(残留分解见 +`菜单页面设计.md` §9.5),再更新脚本常量并在本文变更记录登记。**禁止为过门禁盲目改常量**—— +无法解释的漂移 = 内存回归 bug。 + +## 四、黄金参考更新流程(有意改视觉时) + +1. **先肉眼确认新效果正确**:窗口模式运行 `simulator\build\Debug\lvgl_sim.exe`,或截图转 PNG + (`python simulator/i1_to_png.py --scale 6`)对照 mockup; +2. 刷新参考:`.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update`(重写 `ref/shot_*.bin`); +3. `git status` / `git diff` 确认 `simulator/tests/ref/` 变化符合预期,随代码一起提交。 + +**何时不该更新**:未有意改视觉而某页 FAIL → 是 bug(缺字、几何漂移、样式回归),修代码而不是刷参考。 +菜单状态无黄金参考文件——其断言是结构性的(白带占比/边框/往返一致),不依赖入库位图。 + +## 五、误报排查 + +| 现象 | 常见原因与处置 | +|---|---| +| 多页大面积像素差异 | 字体重生成(`ui_font_cn_12.c` 变化)→ 确认字形正确后 `--update` 刷参考 | +| 单行/单字差异 | 几何或文案变更 → 先判断是否有意修改;无意 = bug | +| 池水位门禁失败但像素一致 | 样式/结构变更(如动态样式转常量、增删框体)→ 确认新值合理后更新常量并登记 | +| `找不到 lvgl_sim.exe` | 模拟器未构建或 build/ 被删 → 先按 §一 重建 | +| 终端输出中文乱码 | PowerShell 代码页渲染问题,不影响判定(看 PASS/FAIL 与退出码) | + +## 六、与其他文档的关系 + +- `UI_界面制作流程.md`:P1 样机生成(`tools/ui_mock/render_mockup.py`)→ P2 设计评审 → **P3 本文回归** → P4 MCU 上机目检 +- `菜单页面设计.md` §9:内容页对象树 / 几何表 / 内存预算(本文数字的来源) +- `simulator/README.md` §3.5/§3.6:无头模式(`--keys`/`--screenshot`)与回归脚本说明 + +## 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-09-09 | Python 解释器统一为仓库 `.venv`(新增 `requirements.txt`,`.gitignore` 忽略 `.venv/`);§1/§2/§4 命令同步改用 `.venv\Scripts\python.exe` | +| 2026-09-09 | 初版:菜单 8 状态像素回归 + 内容页黄金参考回归(ref/ 3 图,I1 原始位图入库)+ 池水位门禁(4784/4864 B)。脚本自会话临时目录整理入 `simulator/tests/`(相对路径定位 exe、输出改临时目录),替代每次重写 | diff --git a/docs/UI 设计/UI_设计架构.md b/docs/UI 设计/UI_设计架构.md index 2d1594f0..7eb72716 100644 --- a/docs/UI 设计/UI_设计架构.md +++ b/docs/UI 设计/UI_设计架构.md @@ -19,6 +19,7 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓 | `Drivers/BSP/UI/ui_menu.c/h` | **共用** | 菜单导航逻辑层:持有导航状态(路径/选中/深度/滚动,共 17 B)+ 键语义,单向驱动显示层;不持 LVGL 对象 | | `Drivers/BSP/UI/ui_menu_view.c/h` | **共用** | 菜单显示层:横向级联框渲染(右贴靠/自适应宽/顶底对齐),持有全部 LVGL 对象与样式、无导航状态 | | `Drivers/BSP/UI/ui_menu_tab.c/h` | **共用** | 菜单数据层:`g_menu_tab[]` 父子索引表 + 查询 API(生成物,勿手改) | +| `Drivers/BSP/UI/ui_content_page.c/h` | **共用** | 内容页模块:装置信息/交流量/直流量三页(独立屏按需建毁,自绘行,零常驻池占用) | | `Drivers/BSP/UI/ui_net_sink.c/h` | MCU | RemoDispBus 服务端(收键 + 回送帧缓冲) | | `Drivers/BSP/UI/ui_font_cn_12.c` | **共用** | 中文 1bpp 子集字体(`lv_font_conv` 生成物) | | `App/task/ui_task.c/h` | MCU | UI 任务:等 NET_READY → 初始化 → 10ms 刷新循环 | @@ -36,7 +37,7 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓 | LVGL 内存池 | 40 KB(`LV_MEM_SIZE`) | `Lib/lv_conf.h` | | 刷新周期 | 10 ms(`ui_port_step(10)` + `osDelay(10)`) | `ui_task.c` | | 推流端口 | 7003(TCP,与 DTU-RemoteLCD 客户端对齐) | `ui_net_sink.h` | -| 默认字体 | `ui_font_cn_12`(12px 1bpp,78 汉字 + ASCII,约 35KB) | `Lib/lv_conf.h` | +| 默认字体 | `ui_font_cn_12`(12px 1bpp,97 汉字 + ASCII 0x20–0x7E + ▶,共 193 字形) | `Lib/lv_conf.h` | | uiTask 栈 | 16384 words(BelowNormal) | `Src/freertos.c` | ### 1.4 硬约束(改 UI 必须遵守) @@ -172,7 +173,7 @@ ENTER/→ 进子框: 销毁最右框(框体连自绘回调整体删除,容器保留)→ 焦点恒在持久容器;父层选中/滚动位原样保留 ``` -- **容器持久、框动态**:行自绘无对象,池内存 = 屏上框体(实测根框 4872 B、最深四级级联 6128 B,远低于 40KB 池) +- **容器持久、框动态**:行自绘无对象,池内存 = 屏上框体(实测根框 4784 B、最深四级级联 6040 B,远低于 40KB 池) - 同节点再次进入:还原该框上次选中与滚动位(`s_sel/s_scroll`);换节点则清零 - 叶子节点(无子项):ENTER 建空白终点框(w=6、h=2 占位),仅 ESC 可返回 - 深度保护:depth+1 ≥ 4 不再下钻 @@ -183,6 +184,16 @@ ENTER/→ 进子框: - 每层容器挂 `menu_key_cb`(user_data = depth),统一分发到逻辑层 `menu_key_handle`:↑↓ 移选(首尾环绕)+ 跟随滚动、→/ENTER 进、←/ESC 退 - 选中变化只改选中数据 + 跟随滚动 + 重绘框体,不重建;滚动位回读持久化到 `s_scroll[d]` +### 4.7 内容页(ui_content_page) + +> 菜单叶子节点进入后的**独立全屏页面**(装置信息/交流量/直流量),与级联框同屏互斥。设计细节见 `菜单页面设计.md` §九。 + +- **对象树**:`screen(新) → cont 全屏容器(flat + CLICK_FOCUSABLE,键入口)`;行全部自绘(无子对象),与级联框同一"行=数据"策略 +- **生命周期 = 按需建毁**:ENTER 叶子(node 1/3/4)→ `lv_obj_create(NULL)` 新建独立屏 + 容器 → 入默认输入组获焦 → `lv_screen_load` 切屏;ESC → 焦点先回菜单持久容器、切回菜单屏,再删容器+屏。菜单屏对象**保留不销毁**(返回即原样还原) +- **导航状态不压层**:菜单 `s_depth/s_sel/s_scroll` 原样保留,内容页只记自身选中行 `s_sel`(每次进入从首行开始) +- **池占用实测**(模拟器 x64,40KB 池):开页峰值 +776 B(屏+容器+事件列表+局部样式),关页后回落;一次性残留 **+80 B**(screens 数组高水位 +8、`lv_screen_load` 给菜单屏设 X/Y 局部样式 +72,均为 LVGL 核心行为、非本模块泄漏) +- **flat 样式用常量样式**(`LV_STYLE_CONST_INIT`):属性存静态数组、零池分配;动态 `lv_style_t` 首次 set_prop 会从池分配 `values_and_props` 且永不释放(x64 实测 88 B/个,MCU 同量级),常驻样式一律用常量样式 + ## 五、设计决策与考量(为什么这样做) ### 5.1 弃用 lv_menu 控件 @@ -201,7 +212,7 @@ ENTER/→ 进子框: ### 5.3 框按需建/销 - LVGL 池仅 40KB(`LV_MEM_SIZE`),全量预建 42 个节点的所有页面必然爆内存 -- 只建"当前路径"上的框:行自绘无对象,池内存 = 屏上框体(实测根框 used=4872 B、最深四级级联 used=6128 B,远低于 40KB 池) +- 只建"当前路径"上的框:行自绘无对象,池内存 = 屏上框体(实测根框 used=4784 B、最深四级级联 used=6040 B,远低于 40KB 池) - 返回即 `lv_obj_delete` 释放,内存随时回落到根框水位 ### 5.4 持久容器是唯一焦点对象 @@ -223,6 +234,7 @@ ENTER/→ 进子框: - mono 主题给所有基元对象附加 card 样式(背景/边框/圆角/内边距/文字色) - 160×160 小屏 1bpp 菜单不需要卡片观感(边框圆角浪费像素) - flat 样式(透明背景/无边框/无圆角/零内边距)**后加优先**,逐项覆盖 card +- **常驻 flat 样式必须用常量样式**(`LV_STYLE_CONST_INIT` + `lv_style_const_prop_t[]`):动态 `lv_style_t` 的 `values_and_props` 是池分配且永不释放(x64 实测 88 B/个),RAM 紧张下每个常驻动态样式都是永久泄漏;常量样式属性存 Flash、零池占用 ### 5.7 SDL 事件只由 LVGL 驱动轮询 @@ -241,3 +253,5 @@ ENTER/→ 进子框: | 2026-09-03 | 关键约束 7:`LV_DRAW_SW_COMPLEX` 须=1(顶部"浅 V"斜线依赖);两端 lv_conf 显式固化,`LV_DRAW_SW_CIRCLE_CACHE_SIZE`=4 防零长数组 | | 2026-09-04 | 菜单重构为"横向级联框"模型(见 `菜单页面设计.md` §四⑧):数据/显示/逻辑三层拆分(新增 `ui_menu_view` 显示层、`ui_menu_tab` 生成物,`ui_menu` 瘦身为纯状态机);字体重生成补缺字形(78 字,含菜单树新增的整/控/软/板等);模拟器加 `--keys ` 无头注键截图验证 | | 2026-09-08 | 菜单 RAM 收敛两连:行对象→框体自绘(行降为数据,§4.4/§5.5);4 层容器→单一持久容器(焦点恒在其上,框深度由逻辑层 `s_depth` 决定,§4.3/§5.4)+ 根框装饰/状态行容器自绘(无对象);LVGL 池实测 根框 4872 B / 最深四级级联 6128 B | +| 2026-09-09 | 内容页模块 `ui_content_page`(§4.7):装置信息/交流量/直流量三叶子页,独立屏按需建毁 + 行自绘;flat 样式改常量样式(`LV_STYLE_CONST_INIT`,零池分配,§5.6);字体 78→97 汉字(193 字形) | +| 2026-09-09 | 菜单显示层 flat 样式同法转常量样式(§5.6):LVGL 池基线 4872→4784 B、最深四级级联 6128→6040 B(x64 省 88 B,MCU 同量级);`LV_STYLE_CONST_INIT` 生成外部链接符号,内容页同名者改名 `cp_style_flat` 避免两文件冲突 | diff --git a/docs/UI 设计/mockups/01_主界面_说明.md b/docs/UI 设计/mockups/01_主界面_说明.md index 492934a9..f4579ed3 100644 --- a/docs/UI 设计/mockups/01_主界面_说明.md +++ b/docs/UI 设计/mockups/01_主界面_说明.md @@ -40,7 +40,7 @@ → **确定新增 8 个字形**:三 当 前 模 式 无 : ▶。 -写 LVGL 代码前必须重新生成字体,否则新增字在真机显示为空白。流程:代码落库 → `python tools/font_gen/extract_cn.py`(自动从 UI 源码字符串提取)→ `& 'C:\ProgramData\anaconda3\python.exe' tools/font_gen/make_font.py`(需 fontTools,见 `UI_中文字体生成与配置.md` §2.1)→ 两端(MCU/模拟器)同时生效。 +写 LVGL 代码前必须重新生成字体,否则新增字在真机显示为空白。流程:代码落库 → `python tools/font_gen/extract_cn.py`(自动从 UI 源码字符串提取)→ `& '.venv\Scripts\python.exe' tools/font_gen/make_font.py`(需 fontTools,见 `UI_中文字体生成与配置.md` §2.1)→ 两端(MCU/模拟器)同时生效。 ## 四、待确认问题 diff --git a/docs/UI 设计/菜单页面设计.md b/docs/UI 设计/菜单页面设计.md index 153e713f..a6a276ec 100644 --- a/docs/UI 设计/菜单页面设计.md +++ b/docs/UI 设计/菜单页面设计.md @@ -1,7 +1,7 @@ # 菜单页面设计 -> **用途**:定义 DTU 主菜单的**内容(42 节点层级树)、视觉规范、三层代码架构**(数据层 / 显示层 / 逻辑层)。 -> **范围**:本阶段**只做菜单层级**(节点、父子、层级、进出导航);各叶子进入后的**内容页**(字段/交互/数据)另行设计,本文不含。 +> **用途**:定义 DTU 主菜单的**内容(42 节点层级树)、视觉规范、三层代码架构**(数据层 / 显示层 / 逻辑层),以及叶子节点进入后的**内容页模块**(§九)。 +> **范围**:菜单层级(节点/父子/层级/进出导航)+ 三个已实现的内容页(装置信息/交流量/直流量,§九);其余叶子的内容页后续按同模式扩展。 > **来源**:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。 > **导航模型**:树形**逐级进入**——`ENTER`/`→` 进入子页,`ESC`/`←` 返回上一级;最多 **4 级(L0~L3)**。 @@ -13,6 +13,7 @@ > | 深入某一层 | §三 数据层 / §五 显示层 / §六 逻辑层(各章自包含:职责边界 + 字段 + API) | > | **改菜单**(增删/改名/换序条目) | §二(内容)+ §3.7(只改文本源、重跑生成器,代码零改动) | > | **改视觉**(框/行/几何) | §四(显示规范)+ §五(显示层实现) | +> | **内容页**(装置信息/交流量/直流量) | §九(对象树/生命周期/页面定义表/API/内存预算) | > | 搞懂**内存**(什么存、什么现算) | §7.3 + §7.5 | > | **验证 / 调试** | §八 | @@ -114,7 +115,7 @@ sequenceDiagram ``` - 日志标记:`UI menu init: OK`(uiTask 打印)——`@test` 回归可作 Required 标记。 -- LVGL 池实测(模拟器 40 KB 池):根框建完 `used=4872 B`(旧行对象实现为 13312 B,自绘 + 容器收敛省 8.2 KB);最深四级级联 `used=6128 B`(增量 1256 B = 3 个子框体),远小于池余量。 +- LVGL 池实测(模拟器 40 KB 池):根框建完 `used=4784 B`(旧行对象实现为 13312 B,自绘 + 容器收敛省 8.3 KB);最深四级级联 `used=6040 B`(增量 1256 B = 3 个子框体),远小于池余量。 ### 1.6 层间不变量(每层"绝不做") @@ -144,7 +145,7 @@ sequenceDiagram | 类型 | 标识 | 行为 | |---|---|---| | **菜单节点** | 行尾 **▶** 箭头 | `ENTER` 进入 → 展开下一级子页 | -| **叶子节点** | 无箭头 | `ENTER` 进入 → **终点**(本阶段仅占位,内容页后续设计) | +| **叶子节点** | 无箭头 | `ENTER` 进入 → 有内容页(node 1/3/4,§九)则切内容屏;否则进空白终点框 | > 根页默认选中**第 1 项"装置信息"**(整行反色:白带、黑字)。 @@ -574,7 +575,7 @@ y + h > 160 → y = 选中行行顶 + ROW_H − h;y < 0 → y = 0(截断/框 |---|---|---|---| | `s_cont` | `lv_obj_t*` | 4 B | 单一全屏持久容器(焦点/键唯一入口;各层框建其内);透明常驻、**永不删**(防焦点对象悬空) | | `s_row_node[4][16]` | `uint8_t` | 64 B | 行数据:节点下标(名称/箭头绘制时现查,无对象);**仅屏上框注册** | -| `s_style_flat/frame/scrollbar` | `lv_style_t`×3 | ~300 B | 样式(一次性初始化,常驻) | +| `s_style_frame/scrollbar` | `lv_style_t`×2(动态)+ flat 常量样式(Flash) | ~200 B | 样式(一次性初始化,常驻;flat 为常量样式零池占用,见 §9.5) | | `s_frame_box[4]` | `lv_obj_t*`×4 | 16 B | 框体(d=0 持久;d>0 进入时建、返回时毁) | | `s_frame[4]` | `menu_frame_t`(built/sel/item_cnt/x/y/w/h) | 48 B | 每框几何缓存:子框对齐要用**父框刚算出**的 x/w/选中行,故按层留一份;随时可由 `s_path`/`s_sel` 重算,**不属于导航状态** | @@ -765,8 +766,8 @@ ESC/LEFT : d>0 → menu_back() (根框 d=0 不响 | 行数据 `s_row_node[d][i]` `[4][16]` | 显示层 | 64 B | | 框体句柄 `s_frame_box[4]` | 显示层 | 16 B | | 框几何缓存 `s_frame[4]`(`menu_frame_t`) | 显示层 | 48 B | -| 样式 `s_style_*`(3 个,一次性) | 显示层 | ~300 B | -| **小计** | | **~0.45 KB**(逻辑层 17 B + 显示层 ~0.4 KB) | +| 样式 `s_style_frame/scrollbar`(动态)+ flat 常量样式(Flash,零池占用 §9.5) | 显示层 | ~200 B | +| **小计** | | **~0.35 KB**(逻辑层 17 B + 显示层 ~0.33 KB) | **LVGL 池(动态,`LV_MEM` 40 KB 预算内)**:行自绘无对象,池只占**屏上框体**(2~3 个 `lv_obj`)+ 根框装饰/状态行, 典型 ≈ **1~2 KB**,仍远小于 40 KB(池可再缩减,见 `docs/待处理内容/RAM使用分析与优化方案.md`)。 @@ -795,7 +796,78 @@ ESC/LEFT : d>0 → menu_back() (根框 d=0 不响 --- -## 九、变更记录 +## 九、内容页(ui_content_page) + +> **一句话职责**:菜单叶子节点进入后的**独立全屏页面**——装置信息(node 1)、交流量(node 3)、直流量(node 4)。与级联框同屏互斥,按需建毁。 +> **文件**:`Drivers/BSP/UI/ui_content_page.c/h`(MCU / PC 模拟器双端共用,注册见 `UI_设计架构.md` §1.2/§1.4 约束 5)。 + +### 9.1 对象树与生命周期 + +```text +菜单屏(持久) 内容页屏幕(按需建毁) +screen_menu screen_page = lv_obj_create(NULL) +└─ cont 持久容器(焦点/键入口) └─ s_cont 全屏容器(flat + CLICK_FOCUSABLE,唯一键入口) + ├─ 根框体 / 子框体… (行全部自绘,无子对象——与级联框同一"行=数据"策略) +``` + +| 动作 | 序列 | +|---|---| +| **开页**(菜单 ENTER 叶子 node 1/3/4) | `lv_obj_create(NULL)` 新建屏 → 建全屏容器(flat 常量样式 + 键回调 + 自绘回调)→ 入默认输入组并获焦(先于切屏,按键立即由本模块接管)→ `lv_screen_load` 切屏。菜单屏对象**保留不销毁** | +| **关页**(ESC/←) | 焦点先回菜单持久容器 → 切回菜单屏(原样还原)→ 删容器、再删屏。LVGL 池内存随即回落 | + +- **导航状态不压层**:菜单 `s_depth/s_sel/s_scroll` 原样保留,返回即还原;内容页只记自身选中行 `s_sel`(每次进入从首行开始,不跨次保留) +- **单页互斥**:同一时刻至多一个内容页打开(`s_open` 哨兵防御) + +### 9.2 页面定义表(Flash 常量,几何实测自参考图 10/20/21) + +| 参数 | 装置信息 | 交流量 | 直流量 | +|---|---|---|---| +| `selectable` | 0(静态键值行) | 1(数据列表) | 1(数据列表) | +| 行数 / 每页 | 6 / 不分页 | 24 / 8(3 页) | 3 / 单页 | +| 首行槽顶 y / 行距 | 40 / 15 | 24 / 14 | 24 / 14 | +| 选中白带高 | 无 | 13(x=2..156) | 13(x=2..156) | +| 左列 x / 值列 x | 标签 6 / 86 | 名 7 / 101 | 名 7 / 101 | +| 副标题 | "馈线自动化终端" y=25 | — | — | +| 页脚(y=143,x=6) | 固定"第 10 页,共 1 页"(按参考图原样) | 动态"第 X 页,共 3 页" | 动态"第 1 页,共 1 页" | + +- 标题 y=2(居中,浅 V 装饰区内;装饰几何与菜单根框一致:两腿 3px / 水平线 2px) +- 列表页窗口 = 选中行所在页(首可见行 = `(sel/每页数)×每页数`),行槽位置固定、数据随页切换 +- 数据表为 Flash `const cp_row_t[]`(名+值字面量,不 strdup);交流量第 2/3 页当前为占位数据 + +### 9.3 键语义 + +| 键 | 行为 | +|---|---| +| ↑ / ↓ | 列表页移选(**首尾环绕、跨页连续**),窗口随页切换后重绘;信息页不响应 | +| ESC / ← | 关页返回菜单(导航状态原样还原) | + +### 9.4 公开 API + +```c +bool ui_content_page_open(content_page_id_t id); /* 开页:建屏+切屏+焦点置页面容器;ID 无效/已有页打开 → false */ +void ui_content_page_close(void); /* 关页:焦点回菜单容器 + 切回菜单屏 + 销毁页面 */ +bool ui_content_page_is_open(void); /* 查询是否有内容页打开 */ +``` + +- 节点→页映射在逻辑层 `ui_menu.c`(`menu_content_page_for(node)`):node 1 → `CONTENT_PAGE_DEVICE_INFO`、3 → `AC`、4 → `DC`;其余叶子无内容页(进空白终点框) + +### 9.5 内存预算(模拟器 x64 实测,40 KB 池) + +| 时刻 | LVGL 池 used | 说明 | +|---|---|---| +| 菜单基线(根框建完) | 4784 B | 同 §1.5 | +| 内容页打开峰值 | 5560~5976 B | +776~1192 B:屏对象+容器+事件列表+局部样式+screens 数组扩容(x64 指针 8B,MCU 32 位下更小) | +| 关页后回落 | **4864 B** | 一次性残留 **+80 B**(见下),非泄漏 | + +**+80 B 一次性残留分解**(均为 LVGL 核心行为,与内容页模块无关;MCU 32 位下更小): +- +8 B:`disp->screens` 数组高水位——开页时第 5 个屏使 TLSF 块 32→40 B,关页缩回后块不归还(分配器行为) +- +72 B:`lv_screen_load()` 内部对菜单屏执行 `lv_obj_set_pos(act_scr,0,0)` → 局部 X/Y 样式各分配一个零初始化 `lv_style_t`+属性数组;每屏一次性,随菜单屏销毁才释放 + +**常驻样式零池占用**:flat 样式用常量样式(`LV_STYLE_CONST_INIT` + `lv_style_const_prop_t[]`,属性存 Flash)。动态 `lv_style_t` 首次 set_prop 会从池分配 `values_and_props` 且永不释放(x64 实测 88 B/个)——常驻样式禁用动态初始化(见 `UI_设计架构.md` §5.6)。 + +--- + +## 十、变更记录 | 日期 | 变更 | |---|---| @@ -826,3 +898,5 @@ ESC/LEFT : d>0 → menu_back() (根框 d=0 不响 | 2026-09-08 | 行 label 指针合并:删 `s_row_text` / `s_row_arrow` 两数组(省 512 B);文本/箭头颜色改在行容器 `s_row` 上设置、子 label 继承(LVGL `text_color` 为可继承属性,`lv_style.c` 属性标志表证实);§5.4 对象模型表 / §7.5 内存预算表同步(768 B→256 B) | | 2026-09-08 | **行对象 → 自绘重写(方案1,省 LVGL 池"每行对象机制税")**:行不再是 LVGL 对象——行数据改 `s_row_node[4][16]`(`uint8_t` 节点下标,256 B→64 B),框体 `LV_EVENT_DRAW_MAIN` 自绘(选中行白带 `lv_draw_rect` + 每行 `lv_draw_label` 文本/箭头,按滚动偏移枚举可见行、重绘区外行 culling);框内滚动改 `LV_EVENT_GET_SELF_SIZE` 虚报内容高 + 手动跟随滚动(`view_scroll_to_sel`,替代 `lv_obj_scroll_to_view`);删 `s_style_selected`、`view_add_row` 与 flex 堆行;`frame_build/destroy/refresh` 改行数据注册/清空/重绘。§5.4/§5.6/§6.2/§7.1~7.5 同步。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器 8 状态像素回归(白带/边框/根框不变性 + 3 组往返态逐像素一致)全 PASS | | 2026-09-08 | **4 层容器→单一持久容器 + 根框装饰/状态行自绘(RAM 收敛)**:焦点/键唯一入口 = 持久容器(框深度由逻辑层 `s_depth` 决定,键回调去掉 `user_data=depth`,`container`/`focus` 接口去参);根框装饰(浅 V)与状态行改容器 `DRAW_MAIN` 自绘(3 段 `lv_draw_line` + 1 行 `lv_draw_label`,删 3 `lv_line` + 1 label + 3 容器对象);静态显示层 ~0.5→~0.4 KB。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器 8 状态像素回归全 PASS(装饰/状态行像素级一致);LVGL 池实测 根框 7168→4872 B、最深四级级联 8488→6128 B(省 ~2.3 KB) | +| 2026-09-09 | **新增 §九"内容页(ui_content_page)"**:装置信息/交流量/直流量三叶子页落地(独立屏按需建毁 + 行自绘,几何实测自参考图 10/20/21);节点→页映射入逻辑层 `menu_content_page_for`(node 1/3/4),§2.1 叶子行为同步;flat 样式改常量样式(`LV_STYLE_CONST_INIT` 零池分配,动态常驻样式 x64 实测 88 B/个永久占用);关页后一次性残留 +80 B 分解入 §9.5(screens 数组高水位 +8、`lv_screen_load` 菜单屏局部 X/Y 样式 +72,均 LVGL 核心行为非泄漏)。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器三页对照参考图几何一致 + 菜单 8 状态像素回归全 PASS | +| 2026-09-09 | 菜单显示层 flat 样式同法转常量样式(§5.6/§7.5/§9.5):LVGL 池基线 4872→4784 B、最深四级级联 6128→6040 B、内容页峰值 5648~6064→5560~5976 B(x64 省 88 B);`LV_STYLE_CONST_INIT` 生成外部链接符号,内容页同名者改名 `cp_style_flat`。验证:`@build` 0 错 0 警 + 代码规范 PASS + 菜单 8 状态像素回归全 PASS + 三页几何不变 | diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..641fa693 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# DTU STM32F4-Base Python deps (unified entry: .venv\Scripts\python.exe) +# Rebuild instructions: see AGENTS.md "Python env" section. +# NOTE: keep this file ASCII-only (pip on Windows decodes it with the locale codepage). +fonttools==4.51.0 # tools/font_gen/make_font.py (font generation, verified version) +pillow==10.3.0 # simulator/i1_to_png.py (screenshot to PNG, verified on this machine) diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 9732cdc1..fd3d1005 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -69,6 +69,7 @@ set(SIM_SOURCES set(UI_SOURCES ${UI_DIR}/ui_menu.c ${UI_DIR}/ui_menu_view.c + ${UI_DIR}/ui_content_page.c ${UI_DIR}/ui_menu_tab.c ${UI_DIR}/ui_font_cn_12.c ) diff --git a/simulator/README.md b/simulator/README.md index b6922c18..686018fc 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -16,6 +16,10 @@ simulator/ ├── sim_port.c / sim_port.h # 模拟器显示/输入端口(SDL 窗口 + 键盘),替代 MCU 端 ui_port.c ├── lv_conf.h # 模拟器专用 LVGL 配置(与 MCU 端 Lib/lv_conf.h 相互独立) ├── i1_to_png.py # 截图转 PNG:把 --screenshot 的 I1(1bpp) 原始位图转成 PNG(见 §3.5) +├── tests/ # 无头回归脚本 + 黄金参考(见 §3.6,流程文档 docs/UI 设计/UI_自动化测试流程.md) +│ ├── check_menu_rows.py # 菜单 8 状态像素回归 + 池基线门禁 +│ ├── check_content_pages.py # 内容页黄金参考回归 + 开页-关页往返池门禁(--update 刷参考) +│ └── ref/ # 黄金参考截图 shot_{info,ac,dc}.bin(I1 原始位图,随仓库提交) ├── dbg_log.h # 调试日志 stub:DBG_* 映射到 printf(覆盖 MCU 版头文件) ├── SDL2/ │ └── SDL2-2.32.10/ # SDL2 预编译 SDK(随仓库提供,无需安装) @@ -38,6 +42,7 @@ simulator/ | `dbg_log.h` | MCU 版 `dbg_log.h` 依赖 `sys_clock.h`(RTC 时间戳),PC 无法编译。此 stub 把 `DBG_INFO/ERROR/DEBUG` 映射到 `printf`;因 `simulator/` 在 include 路径最前,**优先生效**覆盖 MCU 版 | | `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 本目录端口代码,链接预编译 SDL2,产出 `lvgl_sim.exe` | | `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 | +| `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页黄金参考回归)+ 入库黄金参考截图。详见 §3.6 | > **共享代码不复制**:`ui_menu.c` / `ui_font_cn_12.c` 直接引用 `Drivers/BSP/UI/` 下的原件, > MCU 与 PC 两端永远同步。**不要**在本目录内复制 UI 源码。 @@ -170,9 +175,9 @@ cmake --build build --config Debug cd simulator # 1) 离屏渲染首帧,输出 I1(1bpp) 原始位图(已剥离 8 字节调色板,纯像素数据) .\build\Debug\lvgl_sim.exe --screenshot shot.bin -# 2) 转 PNG(默认 160x160;--scale 放大便于查看) -python i1_to_png.py shot.bin # -> shot.png -python i1_to_png.py shot.bin -o 预览.png --scale 6 +# 2) 转 PNG(默认 160x160;--scale 放大便于查看;python 统一用仓库 .venv,见 AGENTS.md「Python 环境」) +..\.venv\Scripts\python.exe i1_to_png.py shot.bin # -> shot.png +..\.venv\Scripts\python.exe i1_to_png.py shot.bin -o 预览.png --scale 6 ``` **I1 位图格式**(`i1_to_png.py` 据此解析):1 位/像素,每字节 MSB 在左(bit7 = 该行最左像素),`bit=1` 前景 / `bit=0` 背景,每行 `宽/8` 字节、行内无填充。 @@ -188,7 +193,27 @@ python i1_to_png.py shot.bin -o 预览.png --scale 6 | `--fg / --bg` | 自定义前/背景色 `R,G,B`(默认黑白) | | `--no-auto-palette` | 关闭调色板自动识别(按纯像素解析) | -> 依赖 Pillow(`pip install pillow`,本仓库已验证)。`shot.bin`/`shot*.png` 均已列入 `.gitignore`,可随时删除重生成。 +> 依赖 Pillow(已含于仓库 `.venv`,见 `requirements.txt`)。`shot.bin`/`shot*.png` 均已列入 `.gitignore`,可随时删除重生成。 + +### 3.6 无头回归测试(tests/) + +UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化测试流程.md`)。脚本纯标准库、任意 python3 均可,为环境统一一律用仓库 `.venv`: + +```powershell +# 仓库根目录执行(前置:本目录已构建出 build/Debug/lvgl_sim.exe) +.venv\Scripts\python.exe simulator/tests/check_menu_rows.py # 菜单 8 状态像素回归 + 池基线门禁 +.venv\Scripts\python.exe simulator/tests/check_content_pages.py # 内容页黄金参考回归 + 开页-关页往返池门禁 +``` + +| 脚本 | 原理 | 失败输出 | +|------|------|----------| +| `check_menu_rows.py` | 键序注入(u/d/e/s)跑 8 个菜单状态,断言选中行白带、非选行黑底白字、框体边框、往返状态逐像素一致;S0 池水位须等于脚本内 `MENU_POOL_BASELINE` | 未过断言清单 + 保留临时截图 | +| `check_content_pages.py` | 三内容页(info=`e` / ac=`dee` / dc=`dede`)无头截图与 `tests/ref/shot_*.bin` **逐字节**比对;另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 差异像素数 + 双方 ink runs 对照 | + +- **黄金参考更新**(有意改视觉后):先肉眼确认新效果正确,再 + `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 刷新 `ref/`,随代码提交。 +- **池水位常量漂移**:菜单/页面结构变更导致门禁失败时,确认新值合理后再更新脚本内常量并登记流程文档变更记录;无法解释的漂移 = 内存回归 bug。 +- 退出码均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等)。 --- @@ -208,7 +233,7 @@ python i1_to_png.py shot.bin -o 预览.png --scale 6 - `LV_USE_THEME_MONO = 1` 且 `sim_port_init` 中调用了 `lv_theme_mono_init` - 主循环调用了 `lv_refr_now(NULL)`(PARTIAL 模式保证首帧) - 没有重复设置 tick(SDL 驱动内部已 `lv_tick_set_cb`,**不要**再 `SDL_AddTimer` + `lv_tick_inc`) -4. **中文显示空白**:字体 `ui_font_cn_12` 是子集字体(当前 78 字),缺字渲染为空白。 +4. **中文显示空白**:字体 `ui_font_cn_12` 是子集字体(当前 97 汉字 + ASCII,共 193 字形),缺字渲染为空白。 补字流程见 `docs/UI 设计/UI_中文字体生成与配置.md`(`tools/font_gen/` 两步脚本)。 5. **改了 UI 代码但模拟器没变化**:确认改的是 `Drivers/BSP/UI/ui_menu.c`(共享原件)而非本目录副本(本目录无副本),然后重新 `cmake --build`。 6. **编码**:UI 源码为 UTF-8(带 BOM),MSVC 已加 `/utf-8`。**不要**用 PowerShell `Set-Content` 等重写含中文的文件(编码铁律,见根目录 `AGENTS.md`)。 diff --git a/simulator/tests/check_content_pages.py b/simulator/tests/check_content_pages.py new file mode 100644 index 00000000..cd47d764 --- /dev/null +++ b/simulator/tests/check_content_pages.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +check_content_pages.py — 三个叶子内容页(装置信息/交流量/直流量)像素黄金参考回归。 + +用法: + python simulator/tests/check_content_pages.py # 与入库黄金参考逐字节比对 + python simulator/tests/check_content_pages.py --update # 有意改视觉后刷新黄金参考 +前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。 + +原理:每页用键序注入打开(info=e / ac=dee / dc=dede),无头截图 I1(160x160) +与 ref/shot_.bin 逐字节比对——任何像素变化即 FAIL(含字体、几何、颜色)。 +另做开页-关页往返池水位门禁:`es` 后 used 须等于 POOL_AFTER_ROUNDTRIP。 + +黄金参考更新流程(有意改视觉时):先肉眼看图确认新效果正确,再 --update 刷新, +最后 git diff 检查 ref/ 变化符合预期。误报排查见 docs/UI 设计/UI_自动化测试流程.md。 + +退出码:0 全 PASS;1 有页不一致或池水位漂移;2 运行/环境错误(exe 缺失、参考缺失)。 +""" +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# lvgl_sim.exe 相对本脚本定位(simulator/tests/ -> simulator/build/Debug/) +EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe" +REF_DIR = Path(__file__).resolve().parent / "ref" + +# (页名, 打开键序):与 ui_menu.c menu_content_page_for() 的节点映射一致 +PAGES = [ + ("info", "e"), # 装置信息(node 1) + ("ac", "dee"), # 交流量(node 3,实时数据下第 1 项) + ("dc", "dede"), # 直流量(node 4,实时数据下第 2 项) +] + +W = H = 160 +STRIDE = W // 8 # I1:20 字节/行 + +# 开页-关页往返后的 LVGL 池水位(x64 模拟器)= 菜单基线 + 一次性残留 80 B。 +# 页面结构变更导致漂移时,先确认新值合理再更新此常量(内存回归门禁)。 +POOL_AFTER_ROUNDTRIP = 4864 + + +def run_sim(seq, out): + r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True) + if r.returncode != 0: + raise RuntimeError("lvgl_sim --keys %r rc=%d: %s" + % (seq, r.returncode, r.stderr.decode(errors="replace")[:200])) + return r.stdout + + +def ink_runs(data): + """I1 位图按行统计白像素,返回 [(y0,y1,count), ...](count>2 的连续段)。""" + px = [] + for y in range(H): + row = data[y * STRIDE:(y + 1) * STRIDE] + cnt = sum((b >> (7 - x)) & 1 for b in row for x in range(8)) + px.append(cnt) + runs, start = [], None + for y, c in enumerate(px): + if c > 2 and start is None: + start = y + elif c <= 2 and start is not None: + runs.append((start, y - 1)) + start = None + if start is not None: + runs.append((start, H - 1)) + return [(a, b, sum(px[a:b + 1])) for a, b in runs] + + +def diff_count(a, b): + n = 0 + for y in range(H): + ra = a[y * STRIDE:(y + 1) * STRIDE] + rb = b[y * STRIDE:(y + 1) * STRIDE] + for x in range(STRIDE): + n += bin(ra[x] ^ rb[x]).count("1") + return n + + +def main(): + update = "--update" in sys.argv[1:] + if not EXE.exists(): + print("FAIL: 找不到 %s(先构建 simulator)" % EXE) + return 2 + + tmp = tempfile.mkdtemp(prefix="dtu_ui_pages_") + ok = True + for name, seq in PAGES: + ref = REF_DIR / ("shot_%s.bin" % name) + shot = os.path.join(tmp, "shot_%s.bin" % name) + run_sim(seq, shot) + data = open(shot, "rb").read() + + if update: + REF_DIR.mkdir(exist_ok=True) + ref.write_bytes(data) + print("[%s] 黄金参考已刷新 -> %s(键序 %r)" % (name, ref.name, seq)) + continue + + if not ref.exists(): + print("FAIL: 缺黄金参考 %s(首次运行请加 --update 生成并入库)" % ref) + shutil.rmtree(tmp, ignore_errors=True) + return 2 + refdata = ref.read_bytes() + if data == refdata: + print("[%s] PASS 与黄金参考一致(键序 %r)" % (name, seq)) + continue + + ok = False + n = diff_count(data, refdata) + print("[%s] FAIL 与黄金参考不一致:%d px 差异(键序 %r)" % (name, n, seq)) + print(" 当前截图 ink runs: %s" % ink_runs(data)) + print(" 黄金参考 ink runs: %s" % ink_runs(refdata)) + print(" 有意改视觉?确认新效果正确后运行 --update 刷新参考。") + + # 开页-关页往返池水位门禁(info 页:e 打开、s 关闭回菜单) + out = run_sim("es", os.path.join(tmp, "shot_rt.bin")) + m = re.search(r"pool after keys: used=(\d+)", out.decode(errors="replace")) + if not m: + print("FAIL: 往返输出未找到 'pool after keys'") + shutil.rmtree(tmp, ignore_errors=True) + return 2 + used = int(m.group(1)) + if update: + print("[rt] 开页-关页往返池水位 %d B(--update 不校验,仅提示)" % used) + elif used != POOL_AFTER_ROUNDTRIP: + ok = False + print("[rt] FAIL 往返池水位 %d != 预期 %d(页面结构变更?确认合理后更新常量)" + % (used, POOL_AFTER_ROUNDTRIP)) + else: + print("[rt] PASS 开页-关页往返池水位 %d B" % used) + + if update: + print("PASS: 黄金参考已刷新(%d 页)" % len(PAGES)) + shutil.rmtree(tmp, ignore_errors=True) + return 0 + + if ok: + print("PASS: 3 内容页 + 往返池水位全部通过") + shutil.rmtree(tmp, ignore_errors=True) + return 0 + # 失败时保留临时截图便于排查 + print("(现场截图保留在 %s)" % tmp) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/simulator/tests/check_menu_rows.py b/simulator/tests/check_menu_rows.py new file mode 100644 index 00000000..63b57160 --- /dev/null +++ b/simulator/tests/check_menu_rows.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +check_menu_rows.py — 自绘菜单 8 状态像素回归(160x160 I1,MSB-first,1=白 0=黑)。 + +用法: + python simulator/tests/check_menu_rows.py # 在仓库任意目录均可运行 +前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。 + +状态(键序注入,u=上 d=下 e=Enter s=Esc): + S0 "" 根框 7 行 sel=0 + S1 "d" sel=1 + S2 "dd" sel=2 + S3 "ddu" 回退 → 须与 S1 全像素一致 + S4 "de" 开 L2(实时数据 3 子项),根框不变 + S5 "ded" L2 sel=1 + S6 "des" 返回 → 须与 S1 全像素一致(销毁/重建一致性) + S7 "ddes" 根框 sel=2、L2 已毁 → 须与 S2 全像素一致 +断言:选中行白带(白占比>0.50)+ 带内黑字;非选行黑底白字(0.03<占比<0.45); + 框体 1px 白边框;一致性状态逐字节相同;S0 池水位 = MENU_POOL_BASELINE。 + +退出码:0 全 PASS;1 有断言失败;2 运行/环境错误(exe 缺失等)。 +""" +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# lvgl_sim.exe 相对本脚本定位(simulator/tests/ -> simulator/build/Debug/) +EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe" + +STATES = [ + ("S0", ""), + ("S1", "d"), + ("S2", "dd"), + ("S3", "ddu"), + ("S4", "de"), + ("S5", "ded"), + ("S6", "des"), + ("S7", "ddes"), +] + +W = H = 160 +STRIDE = W // 8 # I1:20 字节/行 +ROW_H = 15 +BAND_MIN = 0.50 # 选中行白带:白像素占比下限 +NBAND_LO = 0.03 # 非选行:白字占比下限(文本必须存在) +NBAND_HI = 0.45 # 非选行:白像素占比上限(背景须为黑) + +# 根框几何(view_init):x=0,y=44,w=66,h=107 +ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107 + +# LVGL 池基线水位(menu build done,x64 模拟器):菜单结构/样式变更导致漂移时, +# 先确认新值合理再更新此常量(内存回归门禁,见 docs/UI 设计/UI_自动化测试流程.md) +MENU_POOL_BASELINE = 4784 + + +def load(path): + data = open(path, "rb").read() + if len(data) < STRIDE * H: + raise RuntimeError("shot 过短: %s (%d)" % (path, len(data))) + px = [[0] * W for _ in range(H)] + for y in range(H): + row = data[y * STRIDE:(y + 1) * STRIDE] + for x in range(W): + px[y][x] = (row[x // 8] >> (7 - x % 8)) & 1 + return px + + +def frac(px, x1, x2, y1, y2): + tot = wh = 0 + for y in range(y1, y2 + 1): + for x in range(x1, x2 + 1): + tot += 1 + wh += px[y][x] + return wh / tot if tot else 0.0 + + +def root_row_strip(i): + y1 = ROOT_Y0 + 1 + i * ROW_H + return (ROOT_X0 + 1, ROOT_X0 + ROOT_W - 2, y1, y1 + ROW_H - 1) + + +def l2_geom(root_sel, cnt): + x0 = ROOT_X0 + ROOT_W - 1 # 65(共享边框列) + y0 = ROOT_Y0 + 1 + root_sel * ROW_H # 顶对齐父框选中行行顶 + w = 42 # 2+36+2 + 2 边框(3 汉字行) + h = cnt * ROW_H + 2 + return x0, y0, w, h + + +def l2_row_strip(x0, y0, i, w): + y1 = y0 + 1 + i * ROW_H + return (x0 + 1, x0 + w - 2, y1, y1 + ROW_H - 1) + + +fails = [] + + +def check(cond, msg): + if not cond: + fails.append(msg) + + +def raw(path): + return open(path, "rb").read() + + +def main(): + if not EXE.exists(): + print("FAIL: 找不到 %s(先构建 simulator)" % EXE) + return 2 + + tmp = tempfile.mkdtemp(prefix="dtu_ui_rows_") + paths = {} + s0_out = None + try: + for name, seq in STATES: + out = os.path.join(tmp, "rows_%s.bin" % name) + r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True) + if r.returncode != 0: + print("[%s] 运行失败 rc=%d: %s" % (name, r.returncode, + r.stderr.decode(errors="replace")[:200])) + return 2 + paths[name] = out + if name == "S0": + s0_out = r.stdout + + # S0(空键序)= 菜单基线:池水位门禁 + m = re.search(r"pool after keys: used=(\d+)", s0_out.decode(errors="replace")) + if not m: + print("FAIL: S0 输出未找到 'pool after keys'") + return 2 + used = int(m.group(1)) + check(used == MENU_POOL_BASELINE, + "S0 池水位 %d != 基线 %d(菜单结构/样式变更?确认合理后更新常量)" + % (used, MENU_POOL_BASELINE)) + + shots = {n: load(p) for n, p in paths.items()} + S = shots + + def row_check(px, x1, x2, y1, y2, band, tag): + f = frac(px, x1, x2, y1, y2) + if band: + check(f > BAND_MIN, "%s 选中行白占比 %.3f <= %.2f" % (tag, f, BAND_MIN)) + # 带内须有黑字(文本落在白带上) + check(frac(px, x1, x2, y1, y2) < 0.999, "%s 带内无黑字" % tag) + else: + check(NBAND_LO < f < NBAND_HI, "%s 非选行白占比 %.3f 不在 (%.2f,%.2f)" + % (tag, f, NBAND_LO, NBAND_HI)) + + def border_check(px, x0, y0, w, h, tag): + for y in range(y0 + 1, y0 + h - 1): + check(px[y][x0] == 1 and px[y][x0 + w - 1] == 1, + "%s 左右边框 y=%d 非白" % (tag, y)) + for x in range(x0 + 1, x0 + w - 1): + check(px[y0][x] == 1 and px[y0 + h - 1][x] == 1, + "%s 上下边框 x=%d 非白" % (tag, x)) + + # --- S0:根框 7 行,sel=0 --- + p = S["S0"] + border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, "S0根框") + for i in range(7): + row_check(p, *root_row_strip(i), band=(i == 0), tag="S0行%d" % i) + + # --- S1/S2:sel 移位 --- + for name, sel in (("S1", 1), ("S2", 2)): + p = S[name] + border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, name + "根框") + for i in range(7): + row_check(p, *root_row_strip(i), band=(i == sel), tag=name + "行%d" % i) + + # --- S4:L2 开于 sel=1 行右侧(3 子项、无箭头),根框区与 S1 相同 --- + p = S["S4"] + lx0, ly0, lw, lh = l2_geom(1, 3) + border_check(p, lx0, ly0, lw, lh, "S4-L2框") + for i in range(3): + row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 0), tag="S4-L2行%d" % i) + # 根框区(x 0..65)须与 S1 逐像素一致 + for y in range(H): + for x in range(0, ROOT_X0 + ROOT_W): + check(S["S1"][y][x] == p[y][x], "S4 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y)) + # L2 框下方区域须全黑(无残留对象) + f = frac(p, lx0 + 1, lx0 + lw - 2, ly0 + lh + 1, min(ly0 + lh + 14, H - 1)) + check(f == 0.0, "S4 L2 框下方有像素(占比 %.3f)" % f) + + # --- S5:L2 sel=1,根框区仍与 S1 同 --- + p = S["S5"] + lx0, ly0, lw, lh = l2_geom(1, 3) + for i in range(3): + row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 1), tag="S5-L2行%d" % i) + for y in range(H): + for x in range(0, ROOT_X0 + ROOT_W): + check(S["S1"][y][x] == p[y][x], "S5 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y)) + + # --- 一致性状态:逐字节全像素比对 --- + for a, b, tag in (("S3", "S1", "ddu==d"), + ("S6", "S1", "des==d(往返)"), + ("S7", "S2", "ddes==dd(往返)")): + if raw(paths[a]) == raw(paths[b]): + print("[%s] PASS 全像素一致 (%s)" % (a, tag)) + else: + pa, pb = S[a], S[b] + diff = sum(1 for y in range(H) for x in range(W) if pa[y][x] != pb[y][x]) + fails.append("%s 与 %s 不一致(%d px,%s)" % (a, b, diff, tag)) + + if fails: + print("FAIL: %d 项断言未过" % len(fails)) + for msg in fails[:40]: + print(" -", msg) + if len(fails) > 40: + print(" ... 另 %d 项" % (len(fails) - 40)) + return 1 + print("PASS: 8 状态全部断言通过(%d 图,池基线 %d B)" % (len(paths), MENU_POOL_BASELINE)) + return 0 + finally: + # 清理临时截图(失败时保留现场便于排查:仅成功时删) + if not fails: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/simulator/tests/ref/shot_ac.bin b/simulator/tests/ref/shot_ac.bin new file mode 100644 index 00000000..ba2953f9 Binary files /dev/null and b/simulator/tests/ref/shot_ac.bin differ diff --git a/simulator/tests/ref/shot_dc.bin b/simulator/tests/ref/shot_dc.bin new file mode 100644 index 00000000..33d8060c Binary files /dev/null and b/simulator/tests/ref/shot_dc.bin differ diff --git a/simulator/tests/ref/shot_info.bin b/simulator/tests/ref/shot_info.bin new file mode 100644 index 00000000..07d0dc5a Binary files /dev/null and b/simulator/tests/ref/shot_info.bin differ diff --git a/ui_cn_chars.txt b/ui_cn_chars.txt index 262077b6..2f9c21cb 100644 --- a/ui_cn_chars.txt +++ b/ui_cn_chars.txt @@ -1 +1 @@ -▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面: +▶三主事交作保信值制前动区压参双口启告型复字定实式强归当录息手抖护报据控操故数整无时板查检模死波流测清点界直看类系维网置自行装警讯记设询软运通遥量钟防除面:馈线化终端号件版硬校验程序日期第页共,