From 3c5c9d3c15ff2c4f4c5cebf2c1ee6d0eb31cd1b9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 20 Oct 2026 08:18:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E6=94=B9=E5=90=8D=EF=BC=9A=E7=94=BB=E5=B8=83=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E6=97=81=E8=BE=B9=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=88=97=E8=A1=A8=E6=AF=8F=E4=B8=80=E8=A1=8C=E5=A4=9A?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E3=80=8C=E9=87=8D=E5=91=BD=E5=90=8D=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=A4=E5=A4=84=E5=85=B1=E7=94=A8=20canvas/Workflow?= =?UTF-8?q?Picker.renameWorkflow=E2=80=94=E2=80=94=E5=8F=AA=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E5=AD=97=EF=BC=8C=E4=B8=8D=E5=8A=A0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E7=A2=B0=E5=9B=BE=EF=BC=8C=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=B8=8A=E8=BF=98=E6=B2=A1=E4=BF=9D=E5=AD=98=E7=9A=84=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E5=8E=9F=E6=A0=B7=E7=95=99=E7=9D=80=EF=BC=88=E6=94=B9?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E6=98=AF=E6=89=93=E5=BC=80=E7=9A=84=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E5=BC=A0=E6=97=B6=E5=8F=AA=E6=8D=A2=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=90=8D=E5=AD=97=EF=BC=89=E3=80=82=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=20PATCH=20/api/workflows/{id}=20=E7=9A=84=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E5=8E=9F=E6=A0=B7=E5=86=99=E5=85=A5=EF=BC=9A?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8E=BB=E6=8E=89=E9=A6=96=E5=B0=BE=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=EF=BC=8C=E7=A9=BA=E5=90=8D=20422=E3=80=81=E8=B6=85?= =?UTF-8?q?=E8=BF=87=20200=20=E5=AD=97=20422=E3=80=81=E5=92=8C=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=90=8C=E5=90=8D=20409?= =?UTF-8?q?=EF=BC=9B=E5=86=85=E7=BD=AE=E6=A8=A1=E6=9D=BF=E4=B8=8D=E8=AE=B8?= =?UTF-8?q?=E6=94=B9=E5=90=8D=EF=BC=88=E5=90=AF=E5=8A=A8=E6=97=B6=20seed?= =?UTF-8?q?=5Fdefaults=20=E6=8C=89=E5=90=8D=E5=AD=97=E8=A1=A5=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=A8=A1=E6=9D=BF=EF=BC=8C=E6=94=B9=E4=BA=86=E5=90=8D?= =?UTF-8?q?=E4=B8=8B=E6=AC=A1=E5=90=AF=E5=8A=A8=E4=BC=9A=E5=86=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E4=B8=80=E4=BB=BD=E5=8E=9F=E5=90=8D=E7=9A=84=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E7=95=8C=E9=9D=A2=E4=B8=8A=E4=B9=9F=E4=B8=8D=E7=BB=99?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=94=B9=E5=90=8D=E5=85=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E8=A6=81=E6=94=B9=E5=B0=B1=E3=80=8C=E4=BB=A5=E6=AD=A4=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=96=B0=E5=BB=BA=E3=80=8D=E3=80=82=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20tests/test=5Fworkflow=5Frename.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/workflows.py | 29 ++++++++++- backend/tests/test_workflow_rename.py | 66 ++++++++++++++++++++++++++ frontend/src/canvas/WorkflowPicker.tsx | 36 +++++++++++++- frontend/src/pages/StudioPage.tsx | 9 +++- 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_workflow_rename.py diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index a536b491..cc340f01 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -203,6 +203,33 @@ async def get_workflow( return await _workflow_out(session, workflow) +#: 工作流名称最长多少字(workflows.name 是 String(200)) +NAME_MAX = 200 + + +async def _checked_name(session: AsyncSession, workflow: Workflow, given: str) -> str: + """改名:去掉首尾空白,不许空、不许超长、不许和别的工作流同名。 + + 内置模板不许改名:启动时补内置模板是按名字认的(seed.seed_defaults),改了名下次启动会再导入一份原名的。要改名就 + 「以此模板新建」,改新建的那一份。名字没变(只是首尾多了空白)的照常放行。 + """ + name = given.strip() + if not name: + raise HTTPException(422, "工作流名称不能为空") + if len(name) > NAME_MAX: + raise HTTPException(422, f"工作流名称最多 {NAME_MAX} 个字") + if name == workflow.name: + return name + from app.seed import TEMPLATES + + if workflow.is_template and workflow.name in {t["name"] for t in TEMPLATES}: + raise HTTPException(409, "内置模板的名称由系统维护,不能修改:可以「以此模板新建」,再给新建的工作流改名") + taken = await session.scalar(select(Workflow.id).where(Workflow.name == name, Workflow.id != workflow.id).limit(1)) + if taken: + raise HTTPException(409, f"已存在同名工作流「{name}」,请使用其他名称") + return name + + @router.patch("/{workflow_id}", response_model=WorkflowOut) async def update_workflow( workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session) @@ -212,7 +239,7 @@ async def update_workflow( raise HTTPException(404, _GONE) if payload.name is not None: - workflow.name = payload.name + workflow.name = await _checked_name(session, workflow, payload.name) if payload.description is not None: workflow.description = payload.description if payload.tags is not None: diff --git a/backend/tests/test_workflow_rename.py b/backend/tests/test_workflow_rename.py new file mode 100644 index 00000000..a9532e3f --- /dev/null +++ b/backend/tests/test_workflow_rename.py @@ -0,0 +1,66 @@ +"""工作流改名(PATCH /api/workflows/{id} 的 name):去掉首尾空白,不许空、超长、和别的工作流同名;内置模板不许改名。 + +以前名字原样写进去:空名、重名都能存,界面上也没有改名的入口。改名只动名字,不加版本、不碰图。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal +from app.db.models import Workflow +from app.main import app +from app.seed import TEMPLATES + +GRAPH = {"nodes": [{"id": "start", "type": "input", "position": {"x": 0, "y": 0}, + "data": {"label": "开始", "config": {"fields": [{"name": "question"}]}}}], "edges": []} + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def _make(client: AsyncClient, name: str) -> dict: + r = await client.post("/api/workflows", json={"name": name, "graph": GRAPH}) + assert r.status_code in (200, 201), r.text + return r.json() + + +async def test_a_workflow_can_be_renamed_without_a_new_version(client): + w = await _make(client, "周报草稿-改名测试") + r = await client.patch(f"/api/workflows/{w['id']}", json={"name": " 经营周报-改名测试 "}) + assert r.status_code == 200, r.text + got = r.json() + assert got["name"] == "经营周报-改名测试", "首尾空白去掉" + assert got["version"] == w["version"], "只改名不加版本" + assert got["graph"] == w["graph"] + + +async def test_empty_too_long_and_taken_names_are_refused(client): + a = await _make(client, "甲-改名测试") + await _make(client, "乙-改名测试") + r = await client.patch(f"/api/workflows/{a['id']}", json={"name": " "}) + assert r.status_code == 422 and "不能为空" in r.json()["detail"] + r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "长" * 201}) + assert r.status_code == 422 and "最多" in r.json()["detail"] + r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "乙-改名测试"}) + assert r.status_code == 409 and "同名" in r.json()["detail"] + r = await client.patch(f"/api/workflows/{a['id']}", json={"name": "甲-改名测试 "}) + assert r.status_code == 200, "名字没变(只多了空白)照常放行,不算和自己重名" + async with SessionLocal() as s: + assert (await s.get(Workflow, a["id"])).name == "甲-改名测试" + + +async def test_a_built_in_template_keeps_its_name(client): + builtin = TEMPLATES[0]["name"] + async with SessionLocal() as s: + row = Workflow(name=builtin, description="", graph=GRAPH, tags=["template"], is_template=True) + s.add(row) + await s.commit() + wid = row.id + r = await client.patch(f"/api/workflows/{wid}", json={"name": "我的模板-改名测试"}) + assert r.status_code == 409 and "内置模板" in r.json()["detail"], "启动时按名字补内置模板,改了名会再导入一份" + r = await client.patch(f"/api/workflows/{wid}", json={"description": "只改说明可以"}) + assert r.status_code == 200 and r.json()["name"] == builtin diff --git a/frontend/src/canvas/WorkflowPicker.tsx b/frontend/src/canvas/WorkflowPicker.tsx index 1d60e568..ba3ad178 100644 --- a/frontend/src/canvas/WorkflowPicker.tsx +++ b/frontend/src/canvas/WorkflowPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Check, Copy, Layers, LayoutTemplate, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react' +import { Check, Copy, Layers, LayoutTemplate, Pencil, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { useCatalog } from '../store/catalog' @@ -156,6 +156,35 @@ export async function createWorkflow( } } +/** + * 给工作流改名。只改名字:不加版本、不碰图,画布上还没保存的改动原样留着(改的正是画布上打开的这一张时,只换它的名字)。 + * 同名、空名在这里先拦一道,服务端也拦(api/workflows._checked_name);内置模板不给改名入口(启动时按名字补内置模板, + * 服务端会拒),要改就「以此模板新建」。 + */ +export async function renameWorkflow(w: Workflow, refresh: () => Promise | void): Promise { + const names = new Set(useCatalog.getState().workflows.filter((x) => x.id !== w.id).map((x) => x.name)) + const raw = await promptDialog({ + title: `重命名「${w.name}」`, + label: '名称', + initial: w.name, + confirmLabel: '重命名', + validate: (v) => (!v.trim() ? '名称不能为空' : names.has(v.trim()) ? '已存在同名工作流,请使用其他名称' : null), + }) + const name = raw?.trim() + if (!name || name === w.name) return null + try { + const updated = await api.workflows.update(w.id, { name }) + const open = useStudio.getState().workflow + if (open?.id === w.id) useStudio.setState({ workflow: { ...open, name: updated.name } }) + void refresh() + toast.ok(`已重命名为「${updated.name}」`) + return updated + } catch (e) { + toast.error(e) + return null + } +} + function StatusChip({ w }: { w: Workflow }) { const p = w.published_version // 当前发布版的级别看 published_level(同工具栏的版本标签):画布上改过之后 status 回到草稿,受管发布版照实写「受管」 @@ -370,6 +399,11 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () = 以此模板新建 )} + {!w.is_template && ( + void renameWorkflow(w, refresh)} icon={} /> + )} void duplicate(w)} icon={} /> diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index e0fe16fe..67c99ffc 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react' import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { - AlertTriangle, Check, ChevronDown, History, LayoutGrid, MoreHorizontal, PanelRightClose, PanelRightOpen, Plus, + AlertTriangle, Check, ChevronDown, History, LayoutGrid, MoreHorizontal, PanelRightClose, PanelRightOpen, Pencil, Plus, Redo2, RotateCw, Save, ShieldCheck, Square, Undo2, Variable, Wand2, XCircle, } from 'lucide-react' import clsx from 'clsx' @@ -13,7 +13,7 @@ import { CanvasDock, type DockTab } from '../canvas/CanvasDock' import { LineageLayer } from '../canvas/LineageLayer' import { VersionsSheet } from '../canvas/VersionsSheet' import { PublishDialog } from '../canvas/PublishDialog' -import { WorkflowPicker, confirmDiscard, createWorkflow, takeDiscarded } from '../canvas/WorkflowPicker' +import { WorkflowPicker, confirmDiscard, createWorkflow, renameWorkflow, takeDiscarded } from '../canvas/WorkflowPicker' import { fieldOfIssue, problemsOf, type Problem } from '../canvas/issues' import { STUDIO_SHORTCUTS, hintOf, studioShortcut, type StudioShortcutId } from '../canvas/shortcuts' import { AssistantPanel } from '../run/AssistantPanel' @@ -527,6 +527,11 @@ export function StudioPage() { {workflow?.name ?? '选择工作流'} + {/* 改名只改名字,画布上没保存的改动不受影响;内置模板不给改名(见 renameWorkflow) */} + {workflow && !workflow.is_template && ( + void renameWorkflow(workflow, refresh)} icon={} /> + )} {workflow && } {dirty && (