feat(flow): 添加流程信息展示组件及后端支持
新增左上角流程信息展示组件,显示流程编码和名称 后端 FlowDoc 结构增加 name/code/remark 字段支持 添加从 YAML 提取名称的兜底逻辑
This commit is contained in:
@ -26,7 +26,14 @@ pub struct FlowSummary {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")] pub last_modified_by: Option<String>,
|
#[serde(skip_serializing_if = "Option::is_none")] pub last_modified_by: Option<String>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FlowDoc { pub id: String, pub yaml: String, #[serde(skip_serializing_if = "Option::is_none")] pub design_json: Option<serde_json::Value> }
|
pub struct FlowDoc {
|
||||||
|
pub id: String,
|
||||||
|
pub yaml: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")] pub design_json: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")] pub name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")] pub code: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")] pub remark: Option<String>,
|
||||||
|
}
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FlowCreateReq { pub yaml: Option<String>, pub name: Option<String>, pub design_json: Option<serde_json::Value>, pub code: Option<String>, pub remark: Option<String> }
|
pub struct FlowCreateReq { pub yaml: Option<String>, pub name: Option<String>, pub design_json: Option<serde_json::Value>, pub code: Option<String>, pub remark: Option<String> }
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -98,9 +105,13 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
|
|||||||
.or_else(|| req.yaml.as_deref().and_then(extract_name));
|
.or_else(|| req.yaml.as_deref().and_then(extract_name));
|
||||||
let now = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
|
let now = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
|
||||||
let design_json_str = match &req.design_json { Some(v) => serde_json::to_string(v).ok(), None => None };
|
let design_json_str = match &req.design_json { Some(v) => serde_json::to_string(v).ok(), None => None };
|
||||||
|
// 克隆一份用于返回
|
||||||
|
let ret_name = name.clone();
|
||||||
|
let ret_code = req.code.clone();
|
||||||
|
let ret_remark = req.remark.clone();
|
||||||
let am = db_flow::ActiveModel {
|
let am = db_flow::ActiveModel {
|
||||||
id: Set(id.clone()),
|
id: Set(id.clone()),
|
||||||
name: Set(name),
|
name: Set(name.clone()),
|
||||||
yaml: Set(req.yaml.clone()),
|
yaml: Set(req.yaml.clone()),
|
||||||
design_json: Set(design_json_str),
|
design_json: Set(design_json_str),
|
||||||
// 新增: code 与 remark 入库
|
// 新增: code 与 remark 入库
|
||||||
@ -115,7 +126,7 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
|
|||||||
match db_flow::Entity::insert(am).exec(db).await {
|
match db_flow::Entity::insert(am).exec(db).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!(target: "udmin", "flow.create: insert ok id={}", id);
|
info!(target: "udmin", "flow.create: insert ok id={}", id);
|
||||||
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json })
|
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json, name: ret_name, code: ret_code, remark: ret_remark })
|
||||||
}
|
}
|
||||||
Err(DbErr::RecordNotInserted) => {
|
Err(DbErr::RecordNotInserted) => {
|
||||||
// Workaround for MySQL + non-auto-increment PK: verify by reading back
|
// Workaround for MySQL + non-auto-increment PK: verify by reading back
|
||||||
@ -123,7 +134,7 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
|
|||||||
match db_flow::Entity::find_by_id(id.clone()).one(db).await {
|
match db_flow::Entity::find_by_id(id.clone()).one(db).await {
|
||||||
Ok(Some(_)) => {
|
Ok(Some(_)) => {
|
||||||
info!(target: "udmin", "flow.create: found inserted row by id={}, treating as success", id);
|
info!(target: "udmin", "flow.create: found inserted row by id={}, treating as success", id);
|
||||||
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json })
|
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json, name, code: req.code, remark: req.remark })
|
||||||
}
|
}
|
||||||
Ok(None) => Err(anyhow::anyhow!("insert flow failed").context("verify inserted row not found")),
|
Ok(None) => Err(anyhow::anyhow!("insert flow failed").context("verify inserted row not found")),
|
||||||
Err(e) => Err(anyhow::Error::new(e).context("insert flow failed")),
|
Err(e) => Err(anyhow::Error::new(e).context("insert flow failed")),
|
||||||
@ -141,7 +152,12 @@ pub async fn get(db: &Db, id: &str) -> anyhow::Result<FlowDoc> {
|
|||||||
let row = row.ok_or_else(|| anyhow::anyhow!("not found"))?;
|
let row = row.ok_or_else(|| anyhow::anyhow!("not found"))?;
|
||||||
let yaml = row.yaml.unwrap_or_default();
|
let yaml = row.yaml.unwrap_or_default();
|
||||||
let design_json = row.design_json.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
let design_json = row.design_json.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
||||||
Ok(FlowDoc { id: row.id, yaml, design_json })
|
// 名称兜底:数据库 name 为空时,尝试从 YAML 提取
|
||||||
|
let name = row
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| extract_name(&yaml));
|
||||||
|
Ok(FlowDoc { id: row.id, yaml, design_json, name, code: row.code, remark: row.remark })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_code(db: &Db, code: &str) -> anyhow::Result<FlowDoc> {
|
pub async fn get_by_code(db: &Db, code: &str) -> anyhow::Result<FlowDoc> {
|
||||||
@ -152,7 +168,12 @@ pub async fn get_by_code(db: &Db, code: &str) -> anyhow::Result<FlowDoc> {
|
|||||||
let row = row.ok_or_else(|| anyhow::anyhow!("flow not found with code: {}", code))?;
|
let row = row.ok_or_else(|| anyhow::anyhow!("flow not found with code: {}", code))?;
|
||||||
let yaml = row.yaml.unwrap_or_default();
|
let yaml = row.yaml.unwrap_or_default();
|
||||||
let design_json = row.design_json.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
let design_json = row.design_json.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
||||||
Ok(FlowDoc { id: row.id, yaml, design_json })
|
// 名称兜底:数据库 name 为空时,尝试从 YAML 提取
|
||||||
|
let name = row
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| extract_name(&yaml));
|
||||||
|
Ok(FlowDoc { id: row.id, yaml, design_json, name, code: row.code, remark: row.remark })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<FlowDoc> {
|
pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<FlowDoc> {
|
||||||
@ -185,7 +206,7 @@ pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<Flo
|
|||||||
// return latest yaml
|
// return latest yaml
|
||||||
let got = db_flow::Entity::find_by_id(id.to_string()).one(db).await?.unwrap();
|
let got = db_flow::Entity::find_by_id(id.to_string()).one(db).await?.unwrap();
|
||||||
let dj = got.design_json.as_deref().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
let dj = got.design_json.as_deref().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
|
||||||
Ok(FlowDoc { id: id.to_string(), yaml: got.yaml.unwrap_or_default(), design_json: dj })
|
Ok(FlowDoc { id: id.to_string(), yaml: got.yaml.unwrap_or_default(), design_json: dj, name: got.name, code: got.code, remark: got.remark })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(db: &Db, id: &str) -> anyhow::Result<()> {
|
pub async fn delete(db: &Db, id: &str) -> anyhow::Result<()> {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { TestRunButton } from '../testrun/testrun-button';
|
|||||||
import { AddNode } from '../add-node';
|
import { AddNode } from '../add-node';
|
||||||
import { ZoomSelect } from './zoom-select';
|
import { ZoomSelect } from './zoom-select';
|
||||||
import { SwitchLine } from './switch-line';
|
import { SwitchLine } from './switch-line';
|
||||||
import { ToolContainer, ToolSection } from './styles';
|
import { ToolContainer, ToolSection, FlowInfoBadge } from './styles';
|
||||||
import { Readonly } from './readonly';
|
import { Readonly } from './readonly';
|
||||||
import { MinimapSwitch } from './minimap-switch';
|
import { MinimapSwitch } from './minimap-switch';
|
||||||
import { Minimap } from './minimap';
|
import { Minimap } from './minimap';
|
||||||
@ -29,6 +29,8 @@ import { Save } from './save';
|
|||||||
// 基础信息编辑:对齐新建流程弹窗,使用 Antd 的 Modal/Form
|
// 基础信息编辑:对齐新建流程弹窗,使用 Antd 的 Modal/Form
|
||||||
import { Modal as AModal, Form as AForm, Input as AInput, message } from 'antd'
|
import { Modal as AModal, Form as AForm, Input as AInput, message } from 'antd'
|
||||||
import api from '../../../utils/axios'
|
import api from '../../../utils/axios'
|
||||||
|
// 新增:兜底从 YAML 提取流程名称
|
||||||
|
import { parseFlowYaml } from '../../utils/yaml'
|
||||||
|
|
||||||
// 兼容 BrowserRouter 与 HashRouter:优先从 search 获取,若无则从 hash 的查询串中获取
|
// 兼容 BrowserRouter 与 HashRouter:优先从 search 获取,若无则从 hash 的查询串中获取
|
||||||
function getFlowIdFromUrl(): string {
|
function getFlowIdFromUrl(): string {
|
||||||
@ -70,6 +72,44 @@ export const FlowTools = () => {
|
|||||||
const [baseLoading, setBaseLoading] = useState(false)
|
const [baseLoading, setBaseLoading] = useState(false)
|
||||||
const [baseForm] = AForm.useForm()
|
const [baseForm] = AForm.useForm()
|
||||||
|
|
||||||
|
// 新增:显示名称与编码的状态
|
||||||
|
const [metaLoading, setMetaLoading] = useState(false)
|
||||||
|
const [flowName, setFlowName] = useState<string>('')
|
||||||
|
const [flowCode, setFlowCode] = useState<string>('')
|
||||||
|
|
||||||
|
const loadFlowMeta = async () => {
|
||||||
|
const id = getFlowIdFromUrl()
|
||||||
|
if (!id) { setFlowName(''); setFlowCode(''); return }
|
||||||
|
try{
|
||||||
|
setMetaLoading(true)
|
||||||
|
const { data } = await api.get(`/flows/${id}`)
|
||||||
|
const detail: any = data?.data || {}
|
||||||
|
const yaml = String(detail?.yaml || '')
|
||||||
|
const nameFromYaml = (() => { try { return parseFlowYaml(yaml).name || '' } catch { return '' } })()
|
||||||
|
const nextName = String(detail?.name || nameFromYaml || '')
|
||||||
|
const nextCode = String(detail?.code || id)
|
||||||
|
setFlowName(nextName)
|
||||||
|
setFlowCode(nextCode)
|
||||||
|
}catch(e:any){
|
||||||
|
// 失败时至少展示 ID,确保可识别
|
||||||
|
setFlowName('')
|
||||||
|
setFlowCode(id)
|
||||||
|
}finally{
|
||||||
|
setMetaLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFlowMeta()
|
||||||
|
const handler = () => loadFlowMeta()
|
||||||
|
window.addEventListener('hashchange', handler)
|
||||||
|
window.addEventListener('popstate', handler)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', handler)
|
||||||
|
window.removeEventListener('popstate', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openBaseInfo = async () => {
|
const openBaseInfo = async () => {
|
||||||
setBaseOpen(true)
|
setBaseOpen(true)
|
||||||
// 预填现有数据
|
// 预填现有数据
|
||||||
@ -111,6 +151,8 @@ export const FlowTools = () => {
|
|||||||
if (data?.code === 0){
|
if (data?.code === 0){
|
||||||
message.success('已保存')
|
message.success('已保存')
|
||||||
setBaseOpen(false)
|
setBaseOpen(false)
|
||||||
|
// 保存后刷新顶部信息
|
||||||
|
loadFlowMeta()
|
||||||
}else{
|
}else{
|
||||||
throw new Error(data?.message || '保存失败')
|
throw new Error(data?.message || '保存失败')
|
||||||
}
|
}
|
||||||
@ -124,6 +166,19 @@ export const FlowTools = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* 左上角流程信息展示 */}
|
||||||
|
{(flowName || flowCode) && (
|
||||||
|
<FlowInfoBadge>
|
||||||
|
{flowCode ? <span className="code">{flowCode}</span> : null}
|
||||||
|
{flowName ? <span className="name" title={flowName}>{flowName}</span> : null}
|
||||||
|
<span className="actions">
|
||||||
|
<Tooltip content={I18n.t('Edit Base Info')}>
|
||||||
|
<IconButton type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openBaseInfo} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</FlowInfoBadge>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolContainer className="flow-tools">
|
<ToolContainer className="flow-tools">
|
||||||
<ToolSection>
|
<ToolSection>
|
||||||
{/* 返回列表 */}
|
{/* 返回列表 */}
|
||||||
@ -136,16 +191,6 @@ export const FlowTools = () => {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
|
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
|
||||||
{/* 基础信息(名称/编号/备注) */}
|
|
||||||
<Tooltip content={I18n.t('Edit Base Info')}>
|
|
||||||
<IconButton
|
|
||||||
type="tertiary"
|
|
||||||
theme="borderless"
|
|
||||||
icon={<IconEdit />}
|
|
||||||
onClick={openBaseInfo}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
|
|
||||||
<Interactive />
|
<Interactive />
|
||||||
<AutoLayout />
|
<AutoLayout />
|
||||||
<SwitchLine />
|
<SwitchLine />
|
||||||
|
|||||||
@ -50,3 +50,50 @@ export const MinimapContainer = styled.div`
|
|||||||
export const UIIconMinimap = styled(IconMinimap)<{ visible: boolean }>`
|
export const UIIconMinimap = styled(IconMinimap)<{ visible: boolean }>`
|
||||||
color: ${(props) => (props.visible ? undefined : '#060709cc')};
|
color: ${(props) => (props.visible ? undefined : '#060709cc')};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 新增:左上角流程信息展示(编码 + 名称)
|
||||||
|
export const FlowInfoBadge = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid rgba(68, 83, 130, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.04) 0px 2px 6px 0px, rgba(0, 0, 0, 0.02) 0px 4px 12px 0px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
max-width: 60vw;
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2f54eb;
|
||||||
|
background: #f0f5ff;
|
||||||
|
border: 1px solid #adc6ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user