diff --git a/backend/app/apps/derive.py b/backend/app/apps/derive.py index 7fbb84d4..831bffe6 100644 --- a/backend/app/apps/derive.py +++ b/backend/app/apps/derive.py @@ -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 {}), diff --git a/backend/app/apps/tree.py b/backend/app/apps/tree.py index c1314d1d..580ac335 100644 --- a/backend/app/apps/tree.py +++ b/backend/app/apps/tree.py @@ -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]] = [] diff --git a/backend/app/blueprints/templates/_common.py b/backend/app/blueprints/templates/_common.py index 20ee6e0c..1284def9 100644 --- a/backend/app/blueprints/templates/_common.py +++ b/backend/app/blueprints/templates/_common.py @@ -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"]} diff --git a/backend/app/blueprints/templates/approval.py b/backend/app/blueprints/templates/approval.py index a0e058ba..80bdadff 100644 --- a/backend/app/blueprints/templates/approval.py +++ b/backend/app/blueprints/templates/approval.py @@ -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) diff --git a/backend/app/blueprints/templates/freeform.py b/backend/app/blueprints/templates/freeform.py index db886bb4..18a9d2dc 100644 --- a/backend/app/blueprints/templates/freeform.py +++ b/backend/app/blueprints/templates/freeform.py @@ -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: diff --git a/backend/tests/test_app_spec_tools.py b/backend/tests/test_app_spec_tools.py index ace89d43..d1234746 100644 --- a/backend/tests/test_app_spec_tools.py +++ b/backend/tests/test_app_spec_tools.py @@ -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") diff --git a/backend/tests/test_blueprint_templates.py b/backend/tests/test_blueprint_templates.py index 888d890f..7b2804ee 100644 --- a/backend/tests/test_blueprint_templates.py +++ b/backend/tests/test_blueprint_templates.py @@ -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 报销" diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index f3f335eb..2e3bd63d 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -6089,6 +6089,8 @@ export const APP_EDITOR_TEXT = { slotAdd: '新增岗位', slotName: '岗位名', slotLabel: '显示名', + /** 显示名和岗位名一样:派生时没有节点名可用(服务端 apps/tree.slot_label),照实用的是岗位名,不是起好的名字 */ + slotLabelRaw: '还没有起显示名,现在用的是图里的岗位名,可以改成一个好认的名字', slotUnbound: '未绑定:发布检查会拦下流程应用', slotRemove: (name: string) => `删除岗位「${name}」`, grants: '授权', diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index bd031221..4dbb764a 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -60,6 +60,7 @@ export function AccessSection() { setBindings((b) => { const next = { ...b.slots }; delete next[name]; return { ...b, slots: next } }) }} /> + {slot.label.trim() === name &&

{T.slotLabelRaw}

} setBindings((b) => ({ ...b, slots: { ...b.slots, [name]: v.filter((x) => x.kind !== 'everyone') as { kind: 'user' | 'group'; name: string }[] },