Numable 创作文档同一份内容也在终端里:`numable docs <主题>`English

add-page —— 给已有的组件加一张详情页

读者:做工具的用户,和替他干活的 AI。两者读同一份。

目标

让「点一下组件」有去处:组件上只放一眼能看完的结论,细节放进包内的一张页。做完这一章,你的包会多出:

两条路线怎么选:

html 页 xpage 页
写什么 HTML + CSS + JS,数据经 xbridge.runDataFlow 拿 JSON 声明容器与 Canvas,数据经节点 depends 拿
适合 长文、表格、复杂交互、要滚动的详情 与组件同一套画法的板块流,零 WebView
代价 自己写主题、语言、让位这三件事 版式受 RCN 能力约束(画法见 numable docs rcn)

不确定就先写 html:它对排版没有限制,且参数写回(numable docs add-interaction)天然可用。

前置


路线 A:html 详情页

步骤 1 · 建路由表

做什么:新建 page/router.json。首页恒写 "path": "/",且放第一条——有的平台按 path == "/" 找首页,有的取第一条,两条一起守才处处一致。

{
  "routes": [
    { "path": "/", "entry": "html/home/index.html" },
    {
      "path": "/detail",
      "entry": "html/detail/index.html",
      "title": "个股详情",
      "i18n": { "en-US": { "title": "Stock detail" } }
    }
  ]
}

逐字段:

字段 必填 说明
path ✓ / 开头,没有路径参数,要传值用 query(/detail?secid=1.600519)
entry ✓ html / xpage 页相对 page/(html/detail/index.html);form 页是包根相对、自带 page/ 前缀
type html(默认)/ xpage / form,未知值一律当 html
title 裸字段 = manifest.lang 那门语言,译文旁挂同级 i18n(见 numable docs i18n)
present page(默认)/ sheet

容器顶部不画标题,只有一条浮动的 ‹ / ··· / ✕ 胶囊;title 用在返回栈与外部展示上。别指望它给你留出空间——让位是页面自己的事(步骤 2)。

命令

numable check <包>

看到什么算对:router.json 相关无 error(路径写错、JSON 语法错会被 G0 / G12 报出来)。

步骤 2 · 写页面

做什么:建 page/html/detail/index.html。下面这份是最小可用模板,四件事一件都不能少。

<!DOCTYPE html>
<html lang="zh"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>详情</title>
<style>
/* 让位量由容器注入;自己写一份 0 兜底,普通浏览器里预览才不塌 */
:root{ --xb-content-top:0px; --xb-content-bottom:0px;
       --bg:#F5F6F8; --card:#FFFFFF; --fg:#0E1116; --sub:#6B7280; }
html[data-theme="dark"]{ --bg:#0B0C0E; --card:#15171A; --fg:#F3F4F6; --sub:#8E939B; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
  font:14px/1.5 -apple-system,system-ui,"Segoe UI",Roboto,sans-serif;
  /* 容器 chrome 浮在内容之上:顶部让位读 --xb-content-top,不写就被胶囊压住 */
  padding:calc(var(--xb-content-top) + 16px) 16px calc(var(--xb-content-bottom) + 16px)}
h1{font-size:20px;margin:0 0 10px}
.card{background:var(--card);border-radius:16px;padding:14px;margin-top:12px}
.k{color:var(--sub);font-size:12px}
.v{font-size:22px;font-weight:700;margin-top:2px}
.state{color:var(--sub);padding:24px 0;text-align:center}
</style></head>
<body>
<h1 id="name">--</h1>
<div class="card"><div class="k">现价</div><div class="v" id="px">--</div></div>
<div id="err" class="state" hidden></div>

<script>
/* 1. 参数从 query 读 —— 组件 onClick 串里的 ${secid} 是在跳转那一刻求好值的 */
var q = new URLSearchParams(location.search);
var secid = q.get("secid") || "1.600519";

/* 2. 桥不是同步就绪的:轮询等它,拿不到就渲一个不空的降级屏 */
async function ready(){
  for (var i=0;i<24 && !window.xbridge;i++) await new Promise(function(r){setTimeout(r,50)});
  return !!(window.xbridge && xbridge.isXBundleEnv());
}

/* 3. 取数只能走桥 —— 页面里的 fetch / XMLHttpRequest 被容器的 CSP 焊死(connect-src 'none'),
      不报网络错、只是永远不回来。runDataFlow("quote") 找的是 page/flow/quote.df(后缀必须对) */
async function load(){
  var r = await xbridge.runDataFlow("quote", { secid: secid }, 30000);
  /* 桥回的是信封 {code,msg,data}:code === 0 成功,流 resultFilter 透出的那组键在 data 里。
     失败不会 reject,而是 code 非 0(超时是 -2),所以要自己判 */
  if (!r || r.code !== 0) throw new Error((r && r.msg) || "bridge error");
  var d = r.data || {};
  if (!d.name && !d.price) throw new Error("empty");
  return d;
}

(async function boot(){
  if (!(await ready())) { document.getElementById("err").hidden = false;
    document.getElementById("err").textContent = "请在 Numable 里打开"; return; }

  /* 4. 主题与语言由宿主注入:根元素上 data-theme=light|dark、data-lang=<语言码>,
        页面开始加载前就写好了 —— CSS 直接用 html[data-theme="dark"] 即可,不用自己设。
        切换时页面不重载,只派发 themechange / languagechange;有 JS 侧重绘才需要监听 */
  window.addEventListener("themechange", function(e){ /* e.detail.theme = "light"|"dark" */ });
  var info = await xbridge.appInfo();          // 忘了 await 会拿到一个 Promise 对象
  var app = (info && info.data) || {};         // 同上,结果在 data 里
  var lang = String(app.language || "zh").toLowerCase();

  try {
    var data = await load();
    document.getElementById("name").textContent = data.name || "--";
    document.getElementById("px").textContent = data.price || "--";
  } catch (e) {
    document.getElementById("err").hidden = false;
    document.getElementById("err").textContent = "取数失败 · " + e.message;
  }
})();
</script>
</body></html>

四件必做的事,少哪件都是「哪个平台上都不报错、就是不对」:

必做 不做的现象 检查方式
padding-top 读 var(--xb-content-top) 首行被容器胶囊盖住,按钮点不到 人审(打开页面看顶部)
数据走 xbridge.runDataFlow / runActionFlow fetch 的 Promise 永远不 resolve,页面挂在骨架上 人审 / 页面控制台
取数流后缀与方法对上:runDataFlow 只搜 .df,runFlow / runActionFlow 只搜 .af App 里回 -6 flow not found,页面显示「加载失败」 check G12b
明暗写 html[data-theme="dark"] 选择器 跟随不了 App 的明暗(App 锁浅色而系统是深色时尤其明显) 人审

另外两条容易忘的:

命令

numable check <包>

看到什么算对:没有 G12b 的 -6 flow not found 类报错,没有 G15 字号报错。

步骤 3 · 组件接上 onClick

做什么:在 .xwidget 的 events 里写导航串。值解析出来是字符串 = 导航;是结构体(@[file://...af] 或内联对象)= 跑 ActionFlow。

{
  "version": 2,
  "title": "个股",
  "sub": "价格 · 两个月形状",
  "layout": 22,
  "params": { "secid": "1.600519" },
  "events": {
    "onClick": "/detail?secid=${secid}"
  },
  "canvas": {
    "source": "@[file://rc/quote.rcn]",
    "depends": [
      { "flow": "@[file://flow/quote.df]", "params": { "secid": "${secid}" } }
    ]
  }
}

导航串三种写法:

写法 去哪
"/detail?secid=${secid}" 本包的这条路由
"numable://self" 本包首页(/)
"numable://self/page/detail?secid=${secid}" 本包这条路由,等价于第一种

串里的 ${...} 在跳转那一刻求值,作用域按低到高是:外壳参数 ⊕ 这个组件的实例参数 ⊕ 本包 data.* ⊕ depends 的取数结果。只取顶层标量——${obj.field} 这类嵌套取值别指望。

命令

numable check <包>

看到什么算对:写 numable://self/page/<x> 而 router.json 里没有 /x 时,G12 会报「App 里会弹『页面不存在』」;没有这条报错即路由对得上。⚠️ 相对 path 形态(/detail?...)不在 G12 的判据里——它只按 numable:// 形态查表,所以相对写法写错路由名不会被拦,只能自己对着 router.json 核一遍,或改用 numable://self/page/detail 形态让闸帮你查。

步骤 4 · 看效果

先确认页面用的流取得到数,再把页面渲出来看:numable render <包> --page 按手机宽出浅色、暗色两张整页截图,页面里的 runDataFlow 与 run 走同一套白名单和夹具(细节见 numable docs page)。

numable check <包>
numable run <包>            # 页面用的 page/flow/*.df 也会被真跑,入参取 .numable/params/<流名>.json
numable render <包> --page /detail?secid=1.600519

看到什么算对:run 里那条页面流打印 ✓ page/quote {...},键名与页面 JS 里读的字段一致;render --page 的输出里那条 runDataFlow("quote") 是 ✓,.numable/render/page-detail-*.png 两张图里数据都在、顶部没被右上角的胶囊压住。


路线 B:xpage 详情页

xpage 是「整页级 RCN」:没有 WebView,页面由容器 + Canvas 节点声明式拼出来,每个 Canvas 引一份 .rcn,和组件同一套画法。

步骤 1 · 路由声明页型

{
  "routes": [
    { "path": "/", "entry": "html/home/index.html" },
    { "path": "/detail", "type": "xpage", "entry": "xpage/detail.xpage", "title": "详情" }
  ]
}

type 必须写 xpage,漏了会被当 html 去加载一个不存在的网页。

步骤 2 · 写最小 xpage

做什么:建 page/xpage/detail.xpage。

{
  "type": "page",
  "id": "demo-detail",
  "root": {
    "type": "container",
    "id": "root",
    "layout": "list",
    "direction": "vertical",
    "gap": "12pt",
    "padding": "16pt",
    "paddingTop": "${@contentInset.top}pt",
    "paddingBottom": "24pt",
    "depends": [
      { "flow": "@[file://page/flow/quote.df]", "params": { "secid": "${secid}" } }
    ],
    "items": [
      {
        "type": "Canvas",
        "id": "hero",
        "h": "120pt",
        "canvas": { "source": "@[file://page/rc/p-hero.rcn]" }
      },
      {
        "type": "Canvas",
        "id": "stats",
        "h": "96pt",
        "canvas": { "source": "@[file://page/rc/p-stats.rcn]" }
      }
    ]
  }
}

五条硬要求:

要求 不做的现象 检查方式
根容器 paddingTop 必须引 ${@contentInset.top} 顶部被容器胶囊压住,各平台都不报错 check G17(E)
根不能是 layout: "pager" 各平台 pager 分支忽略 padding,让位写了等于没写 check G17(E)
depends 写成 {flow, params} 对象,参数逐个显式列出 裸字符串传空入参,整页渲一片 -- 且流仍报成功 check G12c(E)
路由 query 要用的键(这里的 secid),根节点不要写同名 params 兜底 节点 params 优先级高于路由 query,点谁进来都渲同一个,零报错 人审(拿两个不同参数各点一次)
.xpage 与 page/rc/*.rcn 里的 @[file://...] 基准是包根 解析成空 → 取数不发、Canvas 不画,不报错 人审(写全 page/ 前缀)

对照记:.xwidget 与 xWidget/rc/*.rcn 里的引用基准是 xWidget/(写 @[file://flow/quote.df]),页面这边基准是包根(写 @[file://page/flow/quote.df])。两套基准写反了是最常见的「什么都没发生」。

容器 layout 可选:absolute stack flex flow list pager grid waterfall;叶子节点只有 Canvas 与 input。节点上 depends 走 .df(取数流),events 走 .af(交互流)。完整协议见 numable docs page。

步骤 3 · 节点层文案用页表,组件内文案用 rc 表

xpage 节点层(params / props.text / menu[].label)的 ${@i18n.x} 读的是这一页顶层 i18n 表,.rcn 表里的 key 在这里看不到;key 缺了就渲成空白(numable check G35 会拦)。画在 Canvas 里的文案仍住各块 .rcn 自己的 rc.i18n 表(见 numable docs i18n)。

步骤 4 · 验

numable check <包>
numable run <包> --flow quote --full

看到什么算对:check 无 G17 / G12c 报错;run 里页面流透出的键,与两份 .rcn 里 ${...} 引的键对得上。页面版式用 numable render <包> --page 渲出来看。


常见错

现象 最可能的原因 先做什么
点组件弹「页面不存在」 router.json 里没有那条 path,或 path 拼错 对着 router.json 核 onClick 串;把串改成 numable://self/page/<x> 让 G12 帮你查
点组件开出一张空白页,零报错 用 numable:// 形态写了 onEdit(它逐字匹配 path,只认裸 /edit) onEdit 改裸 path,见 numable docs add-interaction
页面顶部一行被胶囊盖住 html 少了 var(--xb-content-top);xpage 少了 ${@contentInset.top}pt 补让位;xpage 那条 check G17 会拦
页面永远停在骨架上,控制台没有网络错 页面里用了 fetch / XHR 改走 xbridge.runDataFlow
页面显示「加载失败(-6)」 flow 后缀与方法反了:runDataFlow 只搜 .df,runFlow 只搜 .af 改名或改方法,check G12b 会报
详情页所有字段都是 -- 取数流入参没传进去(depends 写成裸字符串,或 query 里的键名与流里不一致) numable run <包> --flow <流名> --full 单独跑一遍,看键
点两个不同的组件,进去是同一个内容 xpage 根节点写了与路由 query 同名的 params 兜底 删掉根上的兜底
iOS 上点搜索框整页被放大 输入控件字号 < 16px 显式写 font-size:16px,check G15 拦
切 App 明暗,页面不跟 页面用了 prefers-color-scheme 改 html[data-theme="dark"] 选择器

下一步