工作流可以改名:画布顶栏名字旁边、工作流列表每一行多一个「重命名」,两处共用 canvas/WorkflowPicker.renameWorkflow——只改名字,不加版本、不碰图,画布上还没保存的改动原样留着(改的正是打开的这一张时只换它的名字)。服务端 PATCH /api/workflows/{id} 的名字以前原样写入:现在去掉首尾空白,空名 422、超过 200 字 422、和别的工作流同名 409;内置模板不许改名(启动时 seed_defaults 按名字补内置模板,改了名下次启动会再导入一份原名的),界面上也不给模板改名入口,要改就「以此模板新建」。测试 tests/test_workflow_rename.py

This commit is contained in:
ThearchyHelios
2026-10-20 08:18:55 +08:00
parent bbc1f3b34c
commit 3c5c9d3c15
4 changed files with 136 additions and 4 deletions
+28 -1
View File
@@ -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:
+66
View File
@@ -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
+35 -1
View File
@@ -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> | void): Promise<Workflow | null> {
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: () =
<Plus size={11} /> 以此模板新建
</button>
)}
{!w.is_template && (
<IconButton label={`重命名「${w.name}」`} title="重命名"
className="text-faint group-hover:text-dim"
onClick={() => void renameWorkflow(w, refresh)} icon={<Pencil size={11} />} />
)}
<IconButton label={`复制「${w.name}」`} title="复制"
className="text-faint group-hover:text-dim"
onClick={() => void duplicate(w)} icon={<Copy size={11} />} />
+7 -2
View File
@@ -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() {
<span className="max-w-56 truncate font-medium @max-[1120px]:max-w-32">{workflow?.name ?? '选择工作流'}</span>
<ChevronDown size={12} className="shrink-0 text-faint" />
</button>
{/* 改名只改名字,画布上没保存的改动不受影响;内置模板不给改名(见 renameWorkflow) */}
{workflow && !workflow.is_template && (
<IconButton label={`重命名「${workflow.name}」`} title="重命名" className="-ml-1 shrink-0 text-faint hover:text-dim"
onClick={() => void renameWorkflow(workflow, refresh)} icon={<Pencil size={12} />} />
)}
{workflow && <VersionLabel workflow={workflow} />}
{dirty && (