diff --git a/core/uiauto_helper.py b/core/uiauto_helper.py index b2efd8a..56afc62 100644 --- a/core/uiauto_helper.py +++ b/core/uiauto_helper.py @@ -12,7 +12,7 @@ API 参考(uiautodev 0.14): - GET /api/android/{serial}/dump_hierarchy — 元素树 JSON """ import requests -from collections import Counter +from collections import Counter, defaultdict from core.logger import get_logger @@ -79,24 +79,32 @@ def get_elements(serial): """获取指定设备的 UI 元素树。 返回 (ok, data_or_error): - ok=True — data 是元素列表 [{name, attrs..., suggested:{type,value,indexed?,occ?,total?,broad?}}, ...] + ok=True — data 是元素列表 [{name, attrs..., suggested:{type,value,semantic?,via?,indexed?,occ?,total?,broad?}}, ...] ok=False — data 是错误消息字符串 元素树由 uiautodev 的 dump_hierarchy 返回(JSON)。我们递归提取每个节点的 关键属性(resource-id/text/description/class/bounds...),供前端列表展示和选择。 - 选择器精度策略(精确到具体按钮的关键): + 选择器精度策略(精确到具体按钮的关键,优先级见 doc/research/U2_ELEMENT_SELECTORS.md §三): 1. 有 resource-id/text/content-desc 的元素: 先预统计该属性在整棵树中的出现次数—— - 唯一出现:直接用属性选择器 //*[@resource-id="x"] - - 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的 - `(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。 + - 重复出现(如抖音底部导航的 tab 同 id,个数还随灰度版本变): + **优先用第二个属性把目标单独圈出来**(语义选择器): + //*[@resource-id="x" and @text="我"] + 它只认"这个元素的文字/id 是什么",不认"同 id 一共几个", + 换设备/换版本依然成立 → 标记 semantic + via(用于限定的属性)。 + - 两个属性组合仍圈不出来时,才退回**整体加括号**的 + `(//*[@resource-id="x"])[k]`(标记 indexed,前端醒目提示脆弱)。 注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是 第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。 2. 无任何属性的元素: 用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位 - (如 //*[@resource-id="x"]/FrameLayout/ImageView[2]); - 整棵树都没有属性时退化为从根开始的结构路径(标记 broad,前端提示脆弱)。 + (如 //*[@resource-id="x"]/*[@class="android.widget.ImageView"][2]); + 整棵树都没有属性时退化为从根开始的结构路径 + (//hierarchy/*[1]/*[3]…,标记 broad,前端提示脆弱)。 + 注意:dump 的 XML 标签一律是 ,class 在 @class 上 —— 任何把 + class 名当标签名的写法(//FrameLayout[1])都永远匹配不到,别再用。 """ if not serial: return False, "缺少 serial" @@ -125,6 +133,25 @@ def get_elements(serial): return False, f"抓取失败: {e}" +def _child_path(path): + """内部路径 `/0/2/1` → XPath 段 `/*[1]/*[3]/*[2]`(按**子节点位置**逐层定位)。 + + 用于"整棵树都没有可用属性"时的兜底结构路径。为什么按位置而不是 `@index`: + 实测 Android dump 里同级节点的 index 属性**会重复**(状态栏/内容区/导航栏 + 三个兄弟的 index 全是 "0"),拿它定位会一次命中好几个。 + """ + return "".join(f"/*[{int(seg) + 1}]" + for seg in path.strip("/").split("/") if seg) + + +def _xpath(conds): + """由条件列表拼 XPath:[(attr, value), ...] → `//*[@a="1" and @b="2"]`。 + + 单个条件即 `//*[@a="1"]`。多个条件是**并列且**,任一条不成立就不匹配。 + """ + return "//*[" + " and ".join(f"@{a}={_xpath_q(v)}" for a, v in conds) + "]" + + def _xpath_q(v): """XPath 字符串字面量:优先双引号,值含双引号时改用单引号包裹(XPath 1.0 无转义)。""" if '"' in v: @@ -134,8 +161,10 @@ def _xpath_q(v): def _extract(root, out): """把 uiautodev 元素树扁平化为可选列表,并为每个节点生成精确选择器建议。""" - # 全树属性出现次数(预统计,供重复元素加 [k] 序号消歧) + # 全树属性出现次数(预统计:判断"这个值重不重复",决定要不要消歧) id_cnt, text_cnt, desc_cnt = Counter(), Counter(), Counter() + # 同属性值 → 该值对应的全部节点 properties(语义消歧要在"同值节点"里比第二个属性) + id_nodes, text_nodes, desc_nodes = defaultdict(list), defaultdict(list), defaultdict(list) # 文档顺序已出现次数(决定当前元素是第几个) seen_id, seen_text, seen_desc = Counter(), Counter(), Counter() @@ -146,15 +175,57 @@ def _extract(root, out): rid, text, desc = (props.get(k, "") for k in ("resource-id", "text", "content-desc")) if rid: id_cnt[rid] += 1 + id_nodes[rid].append(props) if text: text_cnt[text] += 1 + text_nodes[text].append(props) if desc: desc_cnt[desc] += 1 + desc_nodes[desc].append(props) for c in node.get("children") or []: count_attrs(c) - def attr_selector(attr, value, cnt, seen): - """属性选择器:唯一直接出;重复时用 `(…)[k]` 精确到**第 k 个匹配**。 + # 消歧时可用的次要属性(按"越稳越靠前"排:文字 > 描述 > class) + _SECONDARY_ATTRS = ("text", "content-desc", "class") + + def secondary_conds(peers, props): + """属性值重复时,找一组能**把本节点单独圈出来**的次要属性。 + + 例:抖音底部 4 个 tab 共享 resource-id `…:0qf`,但 text 分别是 + 首页/朋友/消息/我 —— 于是 + //*[@resource-id="…:0qf" and @text="我"] + 这种**语义选择器**只依赖"目标自己长什么样",不依赖"同 id 一共几个": + 灰度版把 tab 从 4 个变 3 个、换一台设备,它照样命中。 + 对比 `(…)[k]`:那是"第 k 个匹配",界面一变就指到别的元素上 → 点不中。 + + 返回 ([(attr, value), ...], via) 或 (None, None)。peers 是"同主属性值"的 + 全部节点 properties —— 唯一性只需在这一集合内成立(任何匹配都必然在此集合中)。 + """ + def matched(conds): + return sum(1 for p in peers + if all((p.get(a) or "") == v for a, v in conds)) + + usable = [(a, props.get(a) or "") for a in _SECONDARY_ATTRS] + usable = [(a, v) for a, v in usable if v.strip()] # 空值不能做限定条件 + # 单个次要属性够了就用它(选择器最短) + for a, v in usable: + if matched([(a, v)]) == 1: + return [(a, v)], a + # 单个不够 → 两个次要属性组合(如 @text + @class) + for i in range(len(usable)): + for j in range(i + 1, len(usable)): + conds = [usable[i], usable[j]] + if matched(conds) == 1: + return conds, f"{usable[i][0]}+{usable[j][0]}" + return None, None + + def attr_selector(attr, value, cnt, seen, peers, props): + """属性选择器:唯一直接出;重复时**先语义消歧,再退化到序号**。 + + 语义消歧(2026-09-13 新增,见 doc/research/U2_ELEMENT_SELECTORS.md §五): + //*[@resource-id="x" and @text="我"] ← 不依赖元素个数,换设备/换版本仍成立 + 退而求其次(同 id 同文字都分不开,如列表里的重复项): + (//*[@resource-id="x"])[2] ← 精确到第 2 个匹配,但界面一变就失配 重要(XPath 位置谓词语义): //*[@resource-id="x"][2] → 「在**其父节点**中排第 2 的属性匹配」,**不是**第 2 个匹配 @@ -163,12 +234,20 @@ def _extract(root, out): [2..n] 全部匹配不到 → 运行时"未找到元素"(2026-09-10 实测修复)。 """ seen[value] += 1 - base = f'//*[@{attr}={_xpath_q(value)}]' + conds = [(attr, value)] if cnt[value] > 1: - val = f"({base})[{seen[value]}]" - return val, {"type": "xpath", "value": val, - "indexed": True, "occ": seen[value], "total": cnt[value]} - return base, {"type": "xpath", "value": base} + extra, via = secondary_conds(peers, props) + if extra: + conds += extra + else: + val = f"({_xpath(conds)})[{seen[value]}]" + return val, {"type": "xpath", "value": val, "indexed": True, + "occ": seen[value], "total": cnt[value]} + val = _xpath(conds) + info = {"type": "xpath", "value": val} + if len(conds) > 1: + info.update(semantic=True, via=via) + return val, info def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1): """递归扁平化。 @@ -201,7 +280,10 @@ def _extract(root, out): # 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义; # class 为空的层(tag=*)不参与结构路径,避免 //*[N] 前缀污染导致选择器定位到任意节点 if tag != "*": - seg = f"{tag}[{tag_index}]" + # 步进必须写成 `*[@class="…"]`,**不能**写 `FrameLayout[1]`: + # Android dump 的 XML 里每个元素的**标签名都是 ``**,class 在 + # @class 属性上 —— 写成标签名会永远匹配不到任何东西(见下方 broad 注释)。 + seg = f"*[@class={_xpath_q(cls)}][{tag_index}]" own_tag_path = f"{tag_path}/{seg}" if tag_path else seg else: seg = "" @@ -209,11 +291,14 @@ def _extract(root, out): # ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ---- if rid: - child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id) + child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id, + id_nodes[rid], props) elif text: - child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text) + child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text, + text_nodes[text], props) elif desc: - child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc) + child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc, + desc_nodes[desc], props) elif ctx: val = f"{ctx}/{seg}" suggested = {"type": "xpath", "value": val} @@ -226,8 +311,12 @@ def _extract(root, out): "invalid": True, "reason": "该元素无可用属性(id/文本/class),无法生成可靠选择器"} else: - # 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示) - suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True} + # 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)。 + # 按子节点位置逐层定位 —— 历史实现写成 //FrameLayout[1]/LinearLayout[2] + # 这种"class 当标签名"的路径,**永远零命中**(dump 的标签全是 ); + # 实测 248 个元素里 26 条死选择器,2026-09-13 修复。 + suggested = {"type": "xpath", "value": f"//hierarchy{_child_path(path)}", + "broad": True} child_ctx = None out.append({ diff --git a/doc/API.md b/doc/API.md index 7a9a26a..3fa1e11 100644 --- a/doc/API.md +++ b/doc/API.md @@ -527,10 +527,20 @@ |------|------|---------| | `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" | | `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全) | -| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表,每项带 `suggested`(推荐选择器)、`bounds`、`depth` | +| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` | +| `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,cost_ms}`;抓取弹窗用这个,失败返回 **502** | | `GET /api/uiauto/screenshot?serial=` | — | JPEG | | `POST /api/steps/test` | `{"serial","step":{…}}` | 真机试执行单个步骤 → `命中 / 未找到 / 已执行` | +**`suggested`(推荐选择器)字段**:`{type, value, semantic?, via?, indexed?, occ?, total?, broad?, invalid?, reason?}` + +| 字段 | 含义 | +|------|------| +| `semantic` + `via` | **语义选择器**:`//*[@id="x" and @text="y"]`,`via` 是用于限定的第二属性(`text`/`content-desc`/`class` 或 `a+b`)——不依赖同类元素个数,换设备/换版本仍命中 | +| `indexed` + `occ`/`total` | 退化形式 `(//*[@id="x"])[k]`:同 id 且同文字分不开时才用,**界面一变即失配**,前端黄标提醒 | +| `broad` | 结构路径兜底(`//hierarchy/*[i]/*[j]`),最脆 | +| `invalid` + `reason` | 无任何可用属性,前端禁止回填 | + `GET /api/uiauto/elements` 在 uiautodev 不可用时返回 **503**。设备 dump 慢时可能超时(见 [backlog](backlog/TODO.md))。 --- diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index f21a071..b7fc697 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -206,22 +206,33 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) 历史实现写了前者,导致同 id 多实例(如底部导航 4 个 tab)时 `[2..n]` 全部失配。现在: -- `core/uiauto_helper.py` 生成的都是 `(//*[@…])[k]` 形式(唯一匹配时才省略序号) -- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,`.../FrameLayout[2]` 这类兄弟序号不受影响) +- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,类名/位置步进不受影响) +- 抓取器生成 `(…)[k]` 前**先做语义消歧**(见 §5.4),序号型只是最后的退路 -⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选唯一 id 或文字。 +⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选带**文字**的元素,抓取器会自动产出语义选择器。 ### 5.4 抓取器给的建议选择器 -`GET /api/uiauto/elements` 返回的元素带 `suggested`,生成优先级: +`GET /api/uiauto/elements`、`GET /api/uiauto/snapshot` 返回的元素都带 `suggested`,生成优先级: -1. 有 `resource-id` → `//*[@resource-id="v"]`(重复时带序号 `(…)[k]`,并标 `indexed:true` + 出现次数) -2. 无 id 有 `text` → `//*[@text="v"]` -3. 无 id/text 有 `content-desc` → `//*[@content-desc="v"]` -4. 都没有但有"最近的有属性祖先" → `{祖先}/{Class}[n]` 结构路径 -5. 连祖先都没有 → `//{class_path}`(标 `broad:true`,脆弱,前端会提示) +1. 有 `resource-id` → `//*[@resource-id="v"]`;无 id 但有 `text`/`content-desc` 同理 +2. 该属性值**全树有多个**时,**先用第二个属性把目标单独圈出来**(语义选择器,标 `semantic:true` + `via`): + `//*[@resource-id="v" and @text="我"]`。抖音底部导航同 id 的几个 tab 靠这个解决—— + 灰度版 tab 数量会变(4 个 ↔ 3 个),序号必然错位,文字不会 +3. 两个属性组合仍分不开(列表里同 id 同文字)→ 才退回序号 `(…)[k]`(标 `indexed:true` + `occ`/`total`,前端打**黄标 ⚠ 序号**) +4. 都没有属性、但有"最近的有属性祖先" → `{祖先}/*[@class="android.widget.ImageView"][n]` +5. 连祖先都没有 → `//hierarchy/*[i]/*[j]`(按**子节点位置**逐层,标 `broad:true`,脆弱、前端会提示) 6. 类名也是空 → 标 `invalid:true`(提示无法生成可靠选择器) +**两个必须记住的 XPath 坑**(都实测踩过): + +- **dump 的 XML 标签名一律是 ``**,`class` 在 `@class` 属性上。所以结构步进只能写 + `*[@class="…"]` 或位置 `*[i]`;写成 `//FrameLayout[1]` 这种"类名当标签名"的路径**永远零命中** + (2026-09-13 修:248 个元素里曾有 26 条死选择器) +- **同级节点的 `index` 属性会重复**(状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`), + 兜底结构路径要按**位置** `*[i+1]` 定位,不能按 `@index` + + ### 5.5 抓取与验证(编辑器里的两个按钮) - **「▶ 点一下」**:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素中心),返回吸附结果并刷新截图——确认位置是否可达 diff --git a/doc/backlog/TODO.md b/doc/backlog/TODO.md index d833de7..8bc1907 100644 --- a/doc/backlog/TODO.md +++ b/doc/backlog/TODO.md @@ -86,7 +86,7 @@ - [ ] **序号型 XPath / 界面就绪 的防呆**(2026-09-10 db03f16e 案例) - 背景:`(//*[@resource-id="…"])[6]` 依赖"抓取那一刻该属性有 ≥6 个实例";运行时界面不同(App 仍在闪屏页)→ 序号必然失配。 - - 期望:① `open_app` 提示勾选「等待首页」;② 抓取器对**带序号**的选择器加醒目提示;③ 抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。 + - 期望:① `open_app` 提示勾选「等待首页」;② ~~抓取器对**带序号**的选择器加醒目提示~~(✅ 2026-09-13 已完成,且更进一步:抓取器**优先产出语义选择器**再去掉序号,见 [research/U2_ELEMENT_SELECTORS.md](../research/U2_ELEMENT_SELECTORS.md) §五);③ 抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。 - 涉及:`core/uiauto_helper.py`、`static/admin/editor.js`、`tasks/generic/task.py`、`doc/TASK_DEV.md`。 - [ ] 自定义动作支持 `action_ref` 引用型节点(现状:拖入画布会**展开成 group**,改动需同步执行器 + 编辑器)。 diff --git a/doc/research/U2_ELEMENT_SELECTORS.md b/doc/research/U2_ELEMENT_SELECTORS.md index 9c703fb..49918e5 100644 --- a/doc/research/U2_ELEMENT_SELECTORS.md +++ b/doc/research/U2_ELEMENT_SELECTORS.md @@ -125,16 +125,50 @@ --- -## 五、建议的改动(待排期) +## 五、建议的改动 -1. **抓取器(`core/uiauto_helper.py`)**:同一 id 出现多个实例时,**优先产出 - `//*[@resource-id="x" and @text="…"]`** 而不是 `(…)[k]`;抓取列表里把 - `text`/`content-desc` 放在最显眼位置,让用户能看着选。 -2. **抓取弹窗**:对**带序号**的选择器加醒目提示("这个选择器依赖界面元素个数, - 界面一变就失效")。 -3. **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本), - 其余在编辑器里逐个复核。 -4. **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例", +1. ✅ **抓取器优先产出语义选择器**(2026-09-13 已实现,`core/uiauto_helper._extract`): + 同一属性值出现多个实例时,**先找第二个属性把目标单独圈出来** —— + `//*[@resource-id="x" and @text="…"]`(次属性按 `text` > `content-desc` > `class` + 排,单个不够就两个组合);只有怎么都分不开(列表里同 id 同文字)才退回 + `(…)[k]`。返回值标 `semantic:true` + `via`(限定用的属性)或 `indexed:true`。 + 抓取列表里 **text/content-desc 排在最前并加粗上色**,让用户能看着选。 +2. ✅ **序号型加醒目提示**(同日):前端把 `indexed` 的徽标从蓝色 `#k/n` 换成 + 黄色 **⚠ 序号 k/n**,悬浮说明"依赖同类元素个数,界面一变就指到别的元素上"; + 语义型给绿色 **✓ 语义**;右侧「属性」页新增一行**选择器稳定性**。 +3. ⏳ **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本), + 其余在编辑器里逐个复核。(用户自行处理) +4. ⏳ **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例", 让"序号错位"当场可诊断(现在是干巴巴一句"未找到元素")。 -5. **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验, +5. ✅ **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验, 具体见第四节「解法」。 + +--- + +## 六、顺带修掉的两个"结构性"坑(2026-09-13) + +给 §五.1 做验证时(对真机 dump 逐条用 lxml 求值,比对"命中数==1 且 bounds 一致"), +发现旧实现 248 个元素里有 **26 条选择器永远匹配不到任何东西** —— 都是"结构路径"类: + +| 坑 | 现象 | 原因 | +|---|---|---| +| `//FrameLayout[1]/LinearLayout[2]` | 零命中 | **dump 的 XML 标签名一律是 ``**,class 在 `@class` 属性上 —— 把类名当标签名写的路径永远匹配不到 | +| `//hierarchy/node[@index="1"]` | 零命中或一次命中多个 | 同级节点的 **`index` 属性会重复**(实测状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`) | + +**修法**: + +- 子路径步进改成 `*[@class="android.widget.ImageView"][n]`(谓词 `[n]` 在步进选出的 + 节点集上按文档序定位,语义与原来的"同 class 兄弟序号"一致) +- 兜底结构路径改成按**子节点位置**:`//hierarchy/*[1]/*[3]` + +**效果**(同一份真机 XML,248 个元素): + +| | 旧 | 新 | +|---|---|---| +| 先按 lxml 求值,精确命中目标 | 221 | **247** | +| 零命中(死选择器) | 26 | **0** | +| 语义型 / 序号型 | 0 / 141 | **26 / 115** | + +`core/uiauto_helper.py` 里 `_child_path()` 的注释与 `doc/TASK_DEV.md` §5.4 都记了这两个坑, +避免以后又写回来。 + diff --git a/static/admin/editor.js b/static/admin/editor.js index 4e2b058..2b6b057 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -882,7 +882,12 @@ var _stepEditor={ if(!box)return; if(!el){box.innerHTML='
点左侧图上的元素,或点「层级」里的条目看属性
';return;} var sg=el.suggested||{}; - var rows=[['建议选择器', sg.value||'(无可靠选择器)'], + // 稳定性说明:把"这个选择器靠什么定位"写清楚,用户才知道它会不会因为界面变化而失效 + var stab=sg.semantic?'✓ 语义型 —— 靠 @'+(sg.via||'')+' 限定,不依赖同类元素个数,换设备/换版本仍命中' + :sg.indexed?'⚠ 序号型 —— 依赖「同类元素共 '+sg.total+' 个、取第 '+sg.occ+' 个」,界面一变就失配' + :sg.broad?'⚠ 宽泛 —— 无唯一属性,可能误匹配其他元素' + :(sg.value?'✓ 唯一属性,直接定位':'—'); + var rows=[['建议选择器', sg.value||'(无可靠选择器)'],['选择器稳定性', stab], ['class', el.class||''], ['resource-id', el.resource_id||''], ['text', el.text||''], ['content-desc', el.description||''], ['package', el.package||''], ['clickable', el.clickable||''], @@ -1103,10 +1108,12 @@ var _stepEditor={ var self=this; var html=list.map(function(el){ var idx=self._elData.indexOf(el); + // 属性顺序有讲究:**文字/描述排最前并加粗**——它们是最稳的定位依据, + // 也是"一眼认出这是哪个元素"的凭据(id 只是补充)。见 doc/TASK_DEV.md §5.4。 var attrs=[]; - if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+''); - if(el.text)attrs.push('text:'+esc(el.text)+''); - if(el.description)attrs.push('desc:'+esc(el.description)+''); + if(el.text)attrs.push('text:'+esc(el.text)+''); + if(el.description)attrs.push('desc:'+esc(el.description)+''); + if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+''); var indent=' '.repeat(Math.min(el.depth||0,6)); var sg=el.suggested||{}; if(sg.invalid)attrs.push('⚠ '+esc(sg.reason||'无可用属性')+''); @@ -1125,7 +1132,8 @@ var _stepEditor={ '
'+indent+esc(el.name||el.class||'')+'
'+ '
'+attrs.join('')+'
'+ ''+esc(sg.type)+''+ - (sg.indexed?' #'+sg.occ+'/'+sg.total+'':'')+ + (sg.semantic?' ✓ 语义':'')+ + (sg.indexed?' ⚠ 序号 '+sg.occ+'/'+sg.total+'':'')+ (sg.broad?' ⚠宽泛':'')+ acts+ ''; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 214a6a7..75de8cb 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -324,6 +324,10 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .el-picker-item .ep-info .ep-name{color:var(--text);font-weight:500;word-break:break-all} .el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all} .el-picker-item .ep-info .ep-attrs span{margin-right:8px} +/* 文字/content-desc 是最稳的定位依据,加粗上色让它一眼可见(id 退为补充信息) */ +.el-picker-item .ep-info .ep-attrs .ep-text b{color:#0f766e;font-weight:600} +.el-picker-item .ep-info .ep-attrs .ep-desc b{color:#7c3aed;font-weight:600} +.el-picker-item .ep-info .ep-attrs .ep-id{font-family:var(--mono);opacity:.8} .el-picker-item .ep-sel{font-size:10px;color:#fff;background:var(--primary);padding:1px 7px;border-radius:99px;flex-shrink:0;font-weight:600} /* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */