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 && (