自由形应用的两处小问题:入口按钮「发起」后英文名空一格,岗位显示名不再拿岗位名充数

一、入口按钮写成了「发起accept 报销写回应用」:派生(apps/derive)、审批模板、自由形模板都是 f"发起{名字}" 直接拼。
加 apps/derive.start_label,名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回应用」),三处都用它。

二、岗位标签显示原始名 finance:派生和自由形导入的图里只写了岗位名(画布上随手写的 id),应用里岗位的显示名就填成了它,
看着像起好的名字。加 apps/tree.slot_label:按用到这个岗位的节点在图上的名字拼(派给它的写节点名,几处用到用顿号连;
超时升级给它的写「…超时升级」,同蓝图模板 slot_labels 的叫法;动作确认派给它的写「…发送前确认」);节点没起名字、
没有节点用到它的照实用岗位名。派生和蓝图模板(app_base)共用:卡片上起了名字的(审批模板的步骤名)照旧用卡片上的,
卡片上就是岗位名本身的才按节点名拼。应用编辑器的岗位一节里,显示名和岗位名一样时写一句「还没有起显示名,现在用的是图里的
岗位名」(文案在 lib/terms 的 APP_EDITOR_TEXT.slotLabelRaw),不让人误以为那是起好的名字。

测试:test_blueprint_templates.py 的 test_entry_labels_keep_a_space_before_a_latin_name、
test_slot_labels_use_the_node_names_and_say_so_when_there_is_none、test_derived_apps_use_the_same_slot_labels_and_entry_label;
test_app_spec_tools.py::test_derive_a_process_leaves_slots_empty 原来钉的是旧行为(显示名 = finance),改成节点名「财务审批」。
去掉修复回跑四条都失败。
This commit is contained in:
ThearchyHelios
2026-10-17 12:00:58 +08:00
parent 78e9b0c6da
commit 0b6cde5c0c
9 changed files with 95 additions and 12 deletions
+12 -4
View File
@@ -17,7 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.apps.forms import input_schema
from app.apps.spec import APP_FORMAT, parse_spec
from app.apps.tree import input_fields, node_map, slot_refs
from app.apps.tree import input_fields, node_map, slot_label, slot_refs
PIN = "main"
VIEW = "latest"
@@ -39,6 +39,13 @@ class DeriveRefused(Exception):
self.code = code
def start_label(name: str) -> str:
"""流程类应用入口按钮的说法「发起…」。名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回」),和界面上中英文
之间留空格的写法一致;以前直接拼成「发起accept 报销写回」。派生、蓝图模板(审批、自由形)都用它。"""
text = str(name or "").strip()
return f"发起 {text}" if text[:1].isascii() and text[:1].isalnum() else f"发起{text}"
def _safe(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]", "_", text)
@@ -102,8 +109,9 @@ async def _derive(session: AsyncSession, graph: dict[str, Any], *, title: str, n
"pins": [{**pin, "use": "process" if process else "view"}],
"slots": {}, "entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
}
for ref in slot_refs(graph, writes):
spec["slots"].setdefault(ref["slot"], {"label": ref["slot"]})
refs = slot_refs(graph, writes)
for ref in refs:
spec["slots"].setdefault(ref["slot"], {"label": slot_label(ref["slot"], refs)})
for nid, node in humans.items():
spec["task_ui"][nid] = {"title": node["title"]}
if writes and not lookups:
@@ -113,7 +121,7 @@ async def _derive(session: AsyncSession, graph: dict[str, Any], *, title: str, n
names = list(spec["forms"][ENTRY]["properties"])
first = next((n for n in names if n in spec["forms"][ENTRY]["required"]), names[0] if names else None)
spec["entries"].append({
"id": ENTRY, "label": f"发起{title}" if process else "按条件运行", "pin": PIN,
"id": ENTRY, "label": start_label(title) if process else "按条件运行", "pin": PIN,
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names},
**({"instance_title": {"pattern": f"{title}·{{{first}}}", "fields": [first]}}
if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}),
+16 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import hashlib
from collections.abc import Collection, Iterator
from collections.abc import Collection, Iterable, Iterator
from typing import Any
from app.core.artifact_store import canonical_json
@@ -121,6 +121,21 @@ def slot_refs(graph: Any, writes: Collection[str] = ()) -> list[dict[str, str]]:
return out
#: 按用到它的节点给岗位起显示名(slot_label):派给它的写节点名,超时升级给它的写「…超时升级」(同蓝图模板 slot_labels 的
#: 叫法),动作确认派给它的写「…发送前确认」
_SLOT_USE_LABEL = {"assignee": "{title}", "escalation": "{title}超时升级", "confirm": "{title}发送前确认"}
def slot_label(slot: str, refs: Iterable[dict[str, str]]) -> str:
"""岗位给人看的名字(应用里岗位的显示名)。图里只写了岗位名——画布上随手写的 id,常是英文(finance):以前显示名就是
它,看着像起好的名字。现在按用到它的节点在图上的名字拼(refs 是 slot_refs 的结果,只看这个岗位的那几条,几处用到用顿号
连);节点没起名字(名字就是节点 id)、或者没有节点用到它的,照实用岗位名——界面上显示名和岗位名一样时提示还没起名字。"""
names = [_SLOT_USE_LABEL[r["role"]].format(title=r["title"]) for r in refs
if r.get("slot") == slot and r.get("role") in _SLOT_USE_LABEL and r.get("title")
and r["title"] != r.get("node_id")]
return "、".join(dict.fromkeys(names)) or slot
def input_fields(graph: Any) -> list[dict[str, Any]]:
"""入口节点声明的字段(原样,含新增的 type / enum / format / description)。"""
out: list[dict[str, Any]] = []
+8 -3
View File
@@ -659,7 +659,7 @@ def app_base(bp: dict[str, Any], graph: dict[str, Any], *, use: str, name: str,
writes 是写外部系统的调用工具节点 → 能力名(模板知道哪几个是写入,不用查库)。页面、视图、入口由各模板排。"""
from app.apps.derive import PIN, RECONCILER
from app.apps.spec import APP_FORMAT
from app.apps.tree import node_map, slot_refs
from app.apps.tree import node_map, slot_label, slot_refs
from app.core.artifact_store import graph_hash
ctx = context(bp)
@@ -673,9 +673,14 @@ def app_base(bp: dict[str, Any], graph: dict[str, Any], *, use: str, name: str,
"format": APP_FORMAT, "name": name, "description": description, "icon": "app", "pins": [pin], "slots": {},
"entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
}
# 岗位的显示名:卡片上起了名字的用它(审批模板的步骤名);卡片上就是岗位名本身的(处理人写的是岗位、自由形导入的图)按
# 用到它的节点名拼(apps/tree.slot_label,同派生)
labels = slot_labels(bp)
for ref in slot_refs(graph, writes or {}):
spec["slots"].setdefault(ref["slot"], {"label": labels.get(ref["slot"], ref["slot"])})
refs = slot_refs(graph, writes or {})
for ref in refs:
label = labels.get(ref["slot"])
spec["slots"].setdefault(ref["slot"], {"label": label if label and label != ref["slot"]
else slot_label(ref["slot"], refs)})
for nid, n in node_map(graph).items():
if n["type"] == "human":
spec["task_ui"][nid] = {"title": n["title"]}
+3 -1
View File
@@ -50,7 +50,9 @@ def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
writes = {WRITE: capability} if capability else {}
spec = c.app_base(bp, graph, use="process", name=name, description=str(c.value(bp.get("purpose")) or ""),
writes=writes)
c.entry_and_pages(spec, bp, graph, label=f"发起{name}", writes=writes)
from app.apps.derive import start_label
c.entry_and_pages(spec, bp, graph, label=start_label(name), writes=writes)
return c.finish_app(spec)
+2 -2
View File
@@ -104,7 +104,7 @@ def needs_reconciler(bp: dict[str, Any]) -> bool:
def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, VIEW, _entries, _results
from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, VIEW, _entries, _results, start_label
from app.apps.forms import input_schema
from app.apps.tree import input_fields, node_map
@@ -118,7 +118,7 @@ def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]:
if fields:
spec["forms"][ENTRY] = input_schema(fields)
names = list(spec["forms"][ENTRY]["properties"])
spec["entries"].append({"id": ENTRY, "label": f"发起{name}" if process else "按条件运行", "pin": PIN,
spec["entries"].append({"id": ENTRY, "label": start_label(name) if process else "按条件运行", "pin": PIN,
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names}})
spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}}
if not process:
+2 -1
View File
@@ -344,7 +344,8 @@ async def test_derive_a_process_leaves_slots_empty(monkeypatch):
async with SessionLocal() as session:
spec = await derive_app(session, wf, 1, name="报销审批")
assert spec["name"] == "报销审批" and spec["pins"][0]["use"] == "process"
assert spec["slots"] == {"finance": {"label": "finance"}, "reconciler": {"label": "动作核实"}}
# 岗位的显示名按用到它的节点名起(apps/tree.slot_label),不再拿岗位名 finance 充当显示名
assert spec["slots"] == {"finance": {"label": "财务审批"}, "reconciler": {"label": "动作核实"}}
# 流程的首页只放发起和我发起的:结果在实例页看,不派生「最近封存运行」(派生出来只会挂一块永远空着的印章)
assert "latest" not in spec["views"] and spec["views"]["inst"]["source"]["kind"] == "instance"
home = next(p for p in spec["pages"] if p["id"] == "home")
+49
View File
@@ -299,3 +299,52 @@ def test_freeform_people_rows_follow_the_imported_graph():
stale = [i for i in issues if i["code"] == "bp.template_mismatch" and "gone" in i["message"]]
assert stale and stale[0]["level"] == "warning"
assert freeform.people_rows(c.with_graph(get_template("freeform").example(), freeform.example_graph())) == []
def _people_graph() -> dict:
"""两步审核派给岗位 finance(第一步超时升级给 boss),还有一步派给没起名字的节点用的岗位 ops。名字都是虚构的。"""
return {"nodes": [
c.node("start", "input", "发起", fields=[{"name": "amount", "type": "number"}]),
c.node("a", "human", "初审", mode="approve", assignee={"kind": "slot", "slot": "finance"},
on_timeout={"action": "escalate", "to_slot": "boss"}, due="PT1H"),
c.node("b", "human", "复审", mode="approve", assignee={"kind": "slot", "slot": "finance"}),
{"id": "h3", "type": "human", "position": {"x": 0, "y": 0},
"data": {"config": {"mode": "approve", "assignee": {"kind": "slot", "slot": "ops"}}}},
c.node("done", "output", "完成", fields=[])],
"edges": [c.edge("start", "a"), c.edge("a", "b"), c.edge("b", "h3"), c.edge("h3", "done")]}
def test_entry_labels_keep_a_space_before_a_latin_name():
"""入口按钮「发起…」:名字以英文开头时中间空一格(验收时写成了「发起accept 报销写回应用」)。"""
from app.apps.derive import start_label
assert start_label("accept 报销写回应用") == "发起 accept 报销写回应用"
assert start_label("报销") == "发起报销" and start_label("2026 年报") == "发起 2026 年报"
graph = _people_graph()
inst = instantiate({**get_template("freeform").example()}, name="accept 报销写回应用", graph=graph)
assert inst.app_spec["entries"][0]["label"] == "发起 accept 报销写回应用", inst.app_spec["entries"]
approval = instantiate(get_template("approval").example(), name="erp 报销")
assert approval.app_spec["entries"][0]["label"] == "发起 erp 报销"
def test_slot_labels_use_the_node_names_and_say_so_when_there_is_none():
"""应用里岗位的显示名:图里只写了岗位名(画布上随手写的 id,常是英文 finance),以前显示名就是它,看着像起好的名字。
现在按用到它的节点在图上的名字拼(几处用到用顿号连、超时升级的写「…超时升级」);节点没起名字的照实用岗位名。蓝图卡片
上起了名字的(审批模板的步骤名)照旧用它。"""
inst = instantiate({**get_template("freeform").example()}, name="报销", graph=_people_graph())
slots = inst.app_spec["slots"]
assert slots["finance"]["label"] == "初审、复审", slots
assert slots["boss"]["label"] == "初审超时升级" and slots["ops"]["label"] == "ops", slots
approval = instantiate(get_template("approval").example())
labels = {k: v["label"] for k, v in approval.app_spec["slots"].items()}
assert all(labels[k] != k for k in ("lead", "finance", "finance_up")), labels
async def test_derived_apps_use_the_same_slot_labels_and_entry_label():
from app.apps.derive import derive_from_graph
from app.db.base import SessionLocal
async with SessionLocal() as session:
spec = await derive_from_graph(session, _people_graph(), name="accept 报销", blueprint_id="bp1")
assert {k: v["label"] for k, v in spec["slots"].items()} == {"finance": "初审、复审", "boss": "初审超时升级", "ops": "ops"}
assert spec["entries"][0]["label"] == "发起 accept 报销"
+2
View File
@@ -6089,6 +6089,8 @@ export const APP_EDITOR_TEXT = {
slotAdd: '新增岗位',
slotName: '岗位名',
slotLabel: '显示名',
/** 显示名和岗位名一样:派生时没有节点名可用(服务端 apps/tree.slot_label),照实用的是岗位名,不是起好的名字 */
slotLabelRaw: '还没有起显示名,现在用的是图里的岗位名,可以改成一个好认的名字',
slotUnbound: '未绑定:发布检查会拦下流程应用',
slotRemove: (name: string) => `删除岗位「${name}」`,
grants: '授权',
@@ -60,6 +60,7 @@ export function AccessSection() {
setBindings((b) => { const next = { ...b.slots }; delete next[name]; return { ...b, slots: next } })
}} />
</div>
{slot.label.trim() === name && <p className="text-2xs text-faint" data-app-slot-raw="">{T.slotLabelRaw}</p>}
<PrincipalPicker label={T.slots} value={bound as Picked[]}
onChange={(v) => setBindings((b) => ({
...b, slots: { ...b.slots, [name]: v.filter((x) => x.kind !== 'everyone') as { kind: 'user' | 'group'; name: string }[] },