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

alerts —— 提醒与后台任务

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

目标

给包加一条提醒(到点或条件成立时给用户发一条通知)或一条后台任务(不发通知,定期跑一趟把结果写进本包的 data.*,由组件和页面下次渲染时读)。

两者是同一种文件:xJob/<id>.xjob。一个 Job 就是一个没有画面的组件 —— 外壳(title / sub / i18n / params / form / events)照 .xwidget 来,里面装两块配方:task(跑什么)和 alert(跑完发什么)。

前置

  1. 包已经能跑(numable check 在 personal 档零 error)。
  2. 提醒要判条件的话,包里已经有一条取数流,而且它 resultFilter.keys 里真的透出了你要判的那个键 —— 提醒直接复用它,不另写一份。取数流怎么写见 numable docs df。

步骤 1 · 先认三种形状

没有 type 字段。 型是组合出来的,check 和运行时都按同一条判据认:

有 alert 吗 task 里有 depends 吗 这是什么 它怎么知道该响
有 没有 静态提醒 用户创建那一刻,发什么、什么时候发就全定死了,交给系统定时
有 有 动态提醒 到点先取数,再判条件,判「真」才发
没有 有 后台任务 到点取数,把结果写进 data.*,不发任何通知

判据两句话:要不要通知? 不要 → 只写 task。要 → 写 alert;发不发、发什么,在创建那一刻能不能完全确定? 能 → task 里只写 refresh;不能 → task 里写 depends。

task 与组件的 canvas 同形:canvas 是 {source, depends, refresh},task 是 {depends, then, refresh} —— 去掉画法、加上一段跑完之后的写入。会写组件就会写 Job,depends 的契约逐字相同(各槽独立、按声明顺序合并、入参只从调用点取、看不见别的槽的输出)。

最小写法

// 静态提醒:每天 8 点(3 个键。task 里只有 refresh = 没有数据可判,到点就发)
{
  "title": "喝水",
  "task":  { "refresh": { "at": ["08:00"] } },
  "alert": { "message": { "title": "该喝水了", "body": "一杯 250ml" } }
}
// 动态提醒:有没处理完的事就提醒(5 个键。不新写取数流 —— depends 直接引组件已有的那条)
{
  "title": "待处理",
  "task":  { "depends": [{ "flow": "@[file://xWidget/flow/todo.df]" }],
             "refresh": { "interval": ["1800"] } },
  "alert": { "activeCondition": "$[gt::(${n},0)]",
             "message": { "title": "有 ${n} 件事等你", "body": "点开处理" } }
}
// 后台任务:每天记一个值,不通知(4 个键。没有 alert 块 = 不响)
{
  "title": "记录每日金价",
  "task": { "depends": [{ "flow": "@[file://xWidget/flow/range.df]" }],
            "then": { "flow": "@[file://xJob/flow/record.df]", "params": { "cur": "${cur}" } },
            "refresh": { "at": ["23:55"] } }
}

住 xJob/<id>.xjob,与 xWidget/ 平级;manifest 不用登记,放进目录就算数。depends 引组件的取数流(只读),then 那一段的取数流住 xJob/flow/。.xjob 里 @[file://…] 的路径从包根算起,和 .xwidget 里从 xWidget/ 算起不一样 —— 写错的现象是解析成空,取数根本不发,而且不报错。


步骤 2 · 用 init 生成骨架

命令(在包目录里跑):

numable init --job price --kind cross

--kind 是模板名,生成的文件里没有它。六种:

--kind 生成 什么时候用
static xJob/<id>.xjob 到点就发,不取数
once xJob/<id>.xjob 选一个日期和时间,只响一次(见下文「只响一次的提醒」)
cross xJob/<id>.xjob 越过你设的那条线时发一次(判定用内置配方,见步骤 4)
level xJob/<id>.xjob 条件持续为真时每隔一段时间发一次
changed xJob/<id>.xjob 值和上次不一样就发(判定用内置配方,见步骤 4)
task .xjob + xJob/flow/<id>.df 不发通知,产出写进 data.*

看到什么算对:列出写了哪几个文件,加三条待办。文件已经存在时它什么都不写、直接报错 —— 不会覆盖你改过的东西。裸字段用的语言跟包的 lang 走,--lang 可以现场指定。

生成之后必做一件事:把 task.depends 的 flow 换成本包真有的那条取数流。manifest.minEngine 由 init --job 自动抬到这类提醒要求的引擎版本(越线 / 数值变化 4,一次性提醒 3,其余 2;已经够高就不动),输出里会写出改了什么。以后要是手动改低了,check 会报 G45 —— 版本低了的现象是:装在旧版应用上,这条提醒只是不响,一条错都不报。

也可以在桌面 App 的工作台里做(Mac / Windows):打开这个包,左侧树里「提醒与任务」一组就是 xJob/ 下的文件,「新建提醒 / 任务…」生成的骨架与 init --job 是同一份模板,也会自动抬 minEngine。编辑器把 .xjob 拆成表单逐段填(判定方式可直接选「越线」「超出阈值」「数值变化」配方),旁边实时预览用户会看到的同意面板、通知和接下来的触发时间;点「试运行」真跑一次取数与判定,结果代入预览。改的是同一个 .xjob 文件,CLI 与编辑器可以交替用。


步骤 3 · 逐字段写

外壳

字段 静态 动态 任务 类型 / 一句注意
version 否 否 否 int,缺省 1
id 否 否 否 [a-z0-9-],缺省 = 文件名。别改名:用户建的每一份都按「包 + 规则 id + 参数」记账,换个拼法就是另一条规则,老的那些连同用户填过的东西一起成了孤儿
title 是 是 是 创建面板、提醒管理页、长按组件的菜单都拿它当这条规则的名字
sub 否 否 否 一行副标题
i18n 建议 建议 建议 一层覆盖表,只认四个槽,见步骤 5
params 否 否 禁 默认值表,值恒字符串(数字也写成串);"" = 必填,用户不填不能确认
form 否 否 禁 创建面板字段区的描述,键必须 ⊆ params;字段类型与属性见 numable docs params。不写就一律渲成文本框
events.onClick 否 否 禁 点通知打开什么(包内路由或 numable:// 串),缺省 = 打开包首页。只走导航,不派发交互流 —— 点通知是冷启动,没有流的上下文

「禁」= 后台任务里写了就是 error(G45):任务是包级的,一个包里只有一份,没有用户输入、没有点击。

阈值类参数默认给空,别给一个「合理数字」:你猜的 5000 对盯着 4500 的人是误导。默认值也不本地化。

task:跑什么

字段 静态 动态 任务 一句注意
depends 禁 是 是 取数槽数组,与组件 canvas.depends 逐字同契约,只接 .df。写了就不再是静态提醒
then 禁 否 是 二选一:内置判定配方 {recipe, …}(只用于提醒,见步骤 4),或一个 {flow, params} 绑定(depends 合并完之后跑,写状态只能在这一段)
refresh 是 是 是 见下。缺了它这条规则永远不会被评估(G45)

alert:跑完发什么

字段 静态 动态 任务 一句注意
message 是 是 — {title, body?}。静态提醒的 message 只能引用 params 与 @app —— 创建那一刻别的键根本不存在,引了渲出来是空(G45)
activeCondition 禁 否 — 表达式,结果字面为 1 / true 才算成立。省略 = $[eq::(${hit},1)],读 then 输出的 hit
level 否 否 — quiet / normal / urgent,缺省 normal。作者给默认档,用户可以按自己那份改,但只能压低不能抬高

alert 只认这三个键,多写一个就是 error。别把一个包里的提醒全标 urgent:分档是留给真的不能等的那一条的,一包全 urgent 等于没有分档,用户多半把这个包的通知整体关掉,那时真急的那条也一起哑了(发布档会警告)。

条件写不出来时不要硬拗表达式:把判断挪进 then 那条取数流,让它输出一个 hit。

refresh:节律与冷却

interval / at / tz 的写法与组件完全一样(见 numable docs xwidget),外加两个只有 Job 才有的键:

键 值 语义
cooldown 秒 命中之后冷却:这一次判「真」(任务是成功写入)之后,cooldown 秒内整条规则不再评估 —— 连 then 都不跑。缺省 0
days ["mon"…"sun"] 按星期几过滤 at,缺省每天。只有静态提醒能用

只有静态提醒的 at 里能写 ${参数}(「用户设几点就几点」)。动态的节律归平台管,写了参数就是 error(G45)。

冷却只有这一种语义。要「照常评估但先别响」,在 then 里用 data.* 记下上次响的时刻自己节流。

只响一次的提醒:日期式 at

「10 月 1 日 9 点提醒我交房租」不用改成动态提醒去轮询 —— 把静态提醒的 at 写成日期式 YYYY-MM-DD HH:MM,它就只响一次:

{
  "title": "到时提醒我",
  "params": { "what": "", "date": "", "time": "09:00" },
  "form": {
    "what": { "title": "提醒我做什么", "component": { "type": "textInput" } },
    "date": { "title": "日期", "component": { "type": "datePicker", "props": { "format": "YYYY-MM-DD" } } },
    "time": { "title": "时间", "component": { "type": "timePicker" } }
  },
  "task":  { "refresh": { "at": ["${date} ${time}"] } },
  "alert": { "message": { "title": "${what}", "body": "你定在 ${date} ${time}" } }
}

numable init --job <id> --kind once 生成的就是这个骨架。规则:

事情提前办完了(待办勾掉了、改了时间),用下面的 alert.remove 把它撤掉。


步骤 4 · 判定段 then:状态与边沿

先用内置判定配方

「跌破那一下才响」「和上次不一样才响」这两类最常见的判定,不用写判定流程,在 then 里写一个内置配方,由应用执行:

"then": { "recipe": "cross", "value": "${px}", "line": "${price}", "dir": "${dir}" }
"then": { "recipe": "changed", "keys": ["${ver}", "${state}"] }
配方 字段 什么时候触发
cross 越线 value 数值 · line 线 · dir 方向(below 缺省 / above) 上一次在线的一侧、这一次到了另一侧(below:上次 ≥ 线且这次 < 线)
changed 数值变化 keys:1~4 个值 任一值与上一次不同

判定更复杂时(要看多个字段的组合、要数新增了几条、要恢复时再报一次),才自己写判定流程,见下文。

自己写判定流程

「跌破那一下才响」这类规则要记住上次的值。记在哪里是这一章最容易出事的地方:

取数那一段不能记。 depends 引的是组件也在用的那条取数流,组件每渲一次就把上次的值顶掉,提醒于是永远看不到跨越。所以分两段:depends 只取数(它自己原有的 data.* 缓存照常,不受限制),then 才写状态。

{
  "id": "prevRaw",
  "action": "data.get",
  "params": { "key": "price.last.${dir}.${price}", "default": "" }
}

状态键必须拼上参数

上面那个键里的 ${dir}.${price} 不是命名习惯,是正确性:

同一条规则可以有好几份(盯 5000 的和盯 4500 的是两份,不同股票又是几份),它们在同一个机会里挨个跑 then。共用一个键的话,第一份写完,第二份读到的「上次值」已经等于这一次的值,谁也看不到跨越;不同对象共用更是直接拿别的东西的价当基线。把这条规则的全部 params 都拼进键,data.get 和 data.set 两处都要改。

内置配方不需要这一步(应用按每条提醒分别记)。自己写判定流程时这条不设 lint —— 静态判不准,只能靠你自己守。

空值守卫

取数失败那一次,值是空的。先立一位「这次到底有没有值」的旗标:

{
  "op": "set",
  "props": { "key": "hasCur", "value": "$[if::(eq::(findNotEmpty::(${cur},__none__),__none__),0,1)]" }
}

判空别用 length::。 $[if::(gt::(length::(${cur}),0),1,0)] 是手最顺的写法,也是这一章最值得先记住的一个陷阱:length:: 只对字符串、数组、对象有意义,喂给数字一律返回 0。于是值是数字的那一档,旗标恒为 0、守卫恒不通过、data.set 一次都不执行 —— 而取数流照样报成功、日志干净、界面上什么也看不出来。上面那个哨兵写法对字符串、数字、空串、缺键四种都成立;数字 0 在它这里判成「有值」,这是对的。

同一条流里的上次值也一样:它读回来的类型跟着上一次写进去的那个值走,所以 hasPrev 同样不能用 length:: 判。值是不是数字,取决于取数流那一步是怎么算出来的 —— length::(…)、calc::(…) 出来的都是数字,JSON 里的数字字段也是;拿不准就假定它是数字,哨兵写法两种都兜得住。

立好旗标之后,只在真取到值时才写:

{
  "op": "if",
  "props": { "val": "$[eq::(${hasCur},1)]" },
  "items": [{ "action": "data.set", "params": { "key": "price.last.${dir}.${price}", "value": "${cur}" } }]
}

不守的现象是断网那一刻弹一条「金价跌破」——这是最伤信任的一种错。判断也要一起守:上次值为空时 hit 恒 0,于是新建、换设备、重装之后只建基线、不会把当下已经满足条件的提醒一次全响出来。

平台自己还兜一层:取数流失败、或者 activeCondition 引用的键这一次缺失或是空串,判定结果算「不知道」——不响、也不动任何计时。但它兜不住你在 then 里已经写下去的那一笔。


步骤 5 · 双语

.xjob 只有一层 i18n,而且是元数据表(平台读来直接显示的那类):裸字段是基准语言,i18n[locale] 是对可译字段的同形覆盖,只认四个槽 —— title / sub / message / form。别的键写了不读(G47 会警告)。

form 槽里每个字段能覆盖三样东西,位置都和基准语言里的写法一样:

覆盖什么 写在哪
字段名 form.<键>.title
输入框里的提示文字 form.<键>.component.props.placeholder(或 form.<键>.props.placeholder)
选择类字段的选项文字 form.<键>.component.props.items,按 value 对齐,只换 label
{
  "title": "金价提醒",
  "i18n": {
    "en-US": {
      "title": "Gold price alert",
      "message": { "title": "Gold ${cur}", "body": "Crossed your ${price}" }
    }
  },
  "alert": { "message": { "title": "金价 ${cur}", "body": "已越过你设的 ${price}" } }
}

两件事和内容文案表(.rcn 里那种)不一样,见 numable docs i18n:

选项译文只换文字、不换选项:译文里多写的 value 不会变成新选项,少写的那项回落基准语言的 label。选项集合是参数契约(用户建的每一份按参数记账),不该随语言变。

{
  "params": { "dir": "down" },
  "form": { "dir": { "title": "方向", "component": { "type": "radio", "props": {
    "items": [{ "value": "down", "label": "跌破" }, { "value": "up", "label": "涨破" }] } } } },
  "i18n": { "en-US": { "form": { "dir": { "title": "Direction", "component": { "props": {
    "items": [{ "value": "down", "label": "Falls below" }, { "value": "up", "label": "Rises above" }] } } } } } }
}

输入框的提示文字写了就显示作者的(比如「你认得的叫法即可」),没写才回落 App 自带的「必填」/「选填」—— 值得写。


步骤 6 · 静态提醒看本地状态:alert.skip

「8 点提醒吃药,今天已经记过就别响」——它有本地状态,但不要因此改成动态提醒:动态在手机上只能尽力而为,而漏提醒正是这类场景最糟的方向。

正解是提醒保持静态,在记录「已吃」的那条交互流里顺手撤掉今天那一发:

{ "action": "alert.skip", "params": { "id": "dose", "until": "today" } }

alert.skip 只跳过一段时间,规则和用户建的那份都还在。要整条撤掉,用下一步的 alert.remove。


步骤 7 · 撤销提醒:alert.remove

一次性提醒最常见的收尾:待办完成了、删了、改了时间,旧提醒不该再响。在对应的交互流里写:

{ "id": "rm", "action": "alert.remove", "params": { "id": "once", "params": { "tid": "${tid}" } } }

改时间 = 先 alert.remove 再 alert.add 预填新时刻,让用户在面板上确认。


步骤 8 · 在页面上放一个「加提醒」按钮:alert.add

用户除了长按组件(见文末 jobs)和去「我的 › 提醒」,还可以在你的页面里直接加。按钮的交互流里写:

{ "id": "r", "action": "alert.add", "params": { "id": "price", "params": { "code": "${code}", "price": "" } } }

什么时候会真的响

如实写进你的文案,别承诺「准时」:

组件那边可以声明 jobs,让用户长按组件就能加一条本包的提醒(参数从组件的参数预填,可改)——那一条引用的就是这里的 xJob/<id>,id 写错是 error(G46)。

装包时用户会看到什么

安装 / 更新面板会如实写出这个包带了什么,这是写给用户看的,所以你的 title 要写成用户看得懂的话:

关掉之后那条任务不再跑,then 写的 data.* 就停在关掉那一刻。读这些数据的组件和页面要能兜住「一直没更新」:显示记录的时间,别把老数据当成今天的。


常见错

现象 最可能的原因 先做什么
check 说「这个包用了 xJob/,但 minEngine 低于这组能力要求的引擎轴」 提醒与后台任务需要引擎轴 2 以上的应用(G45) 把 manifest.minEngine 抬到它报的那个版本(2)
check 说「这个包用了 task.refresh.at="YYYY-MM-DD HH:MM" / alert.remove …低于这组能力要求的引擎轴 3」 一次性提醒与撤销提醒需要引擎轴 3 以上的应用(G45) 把 manifest.minEngine 抬到 3
check 说「这个包用了 …(运行时判定配方),但 manifest.minEngine=… 低于这组能力要求的引擎轴 4」 判定配方 task.then.recipe 需要引擎轴 4 以上的应用(G45);写低了在旧版应用上装得上、永远不响 把 manifest.minEngine 抬到 4
只响一次的提醒一直不响 日期不合法(没补零、2 月 30 日),或 date 没填,那一项作废了 numable check 看 G45;面板上日期必选
待办早就完成了,提醒还是响 建提醒时没带业务键,撤销时匹配不上;或者「完成」那条路没挂 alert.remove 建提醒时在 params 里放一个隐藏的业务 id,每条收尾的路都调 alert.remove
提醒一次都没响过 判定拿不到值:depends 那条取数流的 resultFilter.keys 里没有你要判的键 numable run <包> 看那条流真正透出了哪些键,缺的补进 keys
提醒一次都没响过,且一条错都不报 省略了 activeCondition,而 then 又没输出 hit 在 then 的最后 resultFilter 里留下 hit,或者显式写 activeCondition
断网那一刻弹了一条假提醒 then 里没守空值,空值参与了比较 加一位 hasCur 旗标,判定和写回都先过它
同一条规则用户设了两份,只有一份响 状态键没拼参数,两份互相覆盖上次值 把全部 params 拼进 data.get / data.set 的键
换了英文,通知里某个数字位置是空的 译文的 ${} 写丢或写错(G47) 让译文的引用集和基准语言逐字相同
创建面板上少一个字段 form 的键不在 params 里(G45) 两边对齐;params 是那份名单
条件明明成立,却隔很久才响一次 refresh.cooldown 判真之后把整条规则冷却住了 调小或删掉 cooldown
后台任务记下的某天是空的 那天取数失败,却照样写了进去 写入放进空值守卫的分支里
提醒从来不响,后台任务也什么都没记下,却一条错都不报 空值旗标用 length:: 判的,而那个值是数字 —— 旗标恒为 0,写回和判定整条都被守卫挡住了 把旗标改成 findNotEmpty 哨兵写法

下一步