feat: LLM多阶段交互式Schema生成 + UI布局优化

- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文
- Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema
- Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示
- Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列
- Frontend: 流程设计器元数据和模式选择器合并为一行
- Keycloak: 自定义登录主题(中文化)
- 修复CSS媒体查询括号平衡问题
This commit is contained in:
selfrelease
2026-07-19 09:36:42 +08:00
parent a2238bc853
commit 57c8a26147
19 changed files with 853 additions and 14 deletions
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { api } from './api';
import type { Field, ControlType } from './schema';
export type DesignerStage = 'UNDERSTANDING' | 'CLARIFYING' | 'GENERATING' | 'VALIDATING' | 'CONFIRMING';
export type DesignerSuggestion = {
stage: DesignerStage;
formTitle?: string;
formKey?: string;
fields: {
key: string; label: string; control: string; required: boolean;
placeholder?: string; options?: string[]; helperText?: string;
}[];
process?: {
mode: string;
steps: { name: string; assigneeVariable: string }[];
conditionThresholdDays?: number | null;
};
summary: string;
understanding: string;
assumptions: string[];
needsClarification: string[];
validationIssues?: { field: string; issue: string; severity: string }[];
schemaReady: boolean;
};
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
suggestion?: DesignerSuggestion;
};
export type ApplySuggestion = (s: DesignerSuggestion) => void;
const stageLabels: Record<DesignerStage, string> = {
UNDERSTANDING: '🧠 理解需求',
CLARIFYING: '❓ 澄清确认',
GENERATING: '📋 生成 Schema',
VALIDATING: '✅ 校验 Schema',
CONFIRMING: '🎯 等待确认',
};
export function AiDesignerChat({ onApply, contextLabel, getCurrentSchema }: { onApply: ApplySuggestion; contextLabel: string; getCurrentSchema?: () => string }) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: '你好!我是 AI 设计助手。用自然语言描述你想要的表单或审批流程,我来帮你生成。\n\n例如:\n• "报销申请表单,包含报销人、金额、日期、事由"\n• "报销审批:先部门主管审批,超过5000元加财务复核,最后OA管理员审批"' },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [currentStage, setCurrentStage] = useState<DesignerStage>('UNDERSTANDING');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, loading]);
function buildHistory(msgs: ChatMessage[]) {
return msgs.slice(1).map(m => ({ role: m.role, content: m.content }));
}
async function send(presetText?: string) {
const text = (presetText ?? input).trim();
if (!text || loading) return;
setInput('');
const next = [...messages, { role: 'user' as const, content: text }];
setMessages(next);
setLoading(true);
try {
const result = await api<{ suggestion: DesignerSuggestion; model: string }>('/ai/designer-suggestions', {
method: 'POST',
body: JSON.stringify({
message: text,
history: buildHistory(next.slice(0, -1)),
currentSchema: getCurrentSchema?.() ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
}),
});
const s = result.suggestion;
setCurrentStage(s.stage);
const parts = formatSuggestionMessage(s);
setMessages([...next, { role: 'assistant', content: parts, suggestion: s }]);
} catch (e) {
setMessages([...next, { role: 'assistant', content: `⚠️ ${e instanceof Error ? e.message : 'AI 服务暂时不可用'}` }]);
} finally {
setLoading(false);
}
}
return <div className="aiChatPanel">
<div className="aiChatHeader">
<span> AI </span>
<small>{contextLabel}</small>
<span className="aiStageBadge">{stageLabels[currentStage]}</span>
</div>
<div className="aiChatMessages" ref={scrollRef}>
{messages.map((m, i) => <div key={i} className={`aiMsg ${m.role}`}>
<div className="aiMsgContent">{m.content}</div>
{m.suggestion && m.suggestion.fields.length > 0 && (m.suggestion.stage === 'GENERATING' || m.suggestion.stage === 'VALIDATING' || m.suggestion.stage === 'CONFIRMING') && (
<button className="aiApplyBtn" onClick={() => onApply(m.suggestion!)}> </button>
)}
{m.suggestion && m.suggestion.needsClarification.length > 0 && m.suggestion.stage === 'CLARIFYING' && (
<div className="aiQuickReplies">
{m.suggestion.needsClarification.map((q, qi) => <button key={qi} className="aiQuickReply" onClick={() => void send(q)}>{q}</button>)}
</div>
)}
{m.suggestion && m.suggestion.schemaReady && (
<div className="aiSchemaReady"> Schema </div>
)}
</div>)}
{loading && <div className="aiMsg assistant"><div className="aiMsgContent aiTyping"></div></div>}
</div>
<div className="aiChatInput">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
placeholder={currentStage === 'CONFIRMING' ? '输入"确认"或描述调整…' : '描述你想要的表单或流程…'}
rows={2}
disabled={loading}
/>
<button onClick={() => void send()} disabled={loading || !input.trim()}></button>
</div>
</div>;
}
function formatSuggestionMessage(s: DesignerSuggestion): string {
const parts: string[] = [];
if (s.understanding) parts.push(`🧠 我的理解:${s.understanding}`);
if (s.needsClarification.length > 0) parts.push(`❓ 需要澄清:\n${s.needsClarification.map((q, i) => `${i + 1}. ${q}`).join('\n')}`);
if (s.fields.length > 0) parts.push(`📋 表单字段(${s.fields.length} 个):\n${s.fields.map(f => `${f.label}${f.control}${f.required ? ',必填' : ''}`).join('\n')}`);
if (s.process) parts.push(`🔄 审批流程(${s.process.mode}):${s.process.steps.map(st => st.name).join(' → ')}`);
if (s.validationIssues && s.validationIssues.length > 0) parts.push(`⚠️ 校验问题:\n${s.validationIssues.map(v => ` • [${v.severity}] ${v.field}: ${v.issue}`).join('\n')}`);
if (s.assumptions.length > 0) parts.push(`💡 假设:${s.assumptions.join('')}`);
if (s.summary) parts.push(`📝 ${s.summary}`);
if (s.schemaReady) parts.push(`✅ Schema 已就绪,请确认或调整`);
return parts.join('\n\n') || '已处理';
}
export function suggestionToFields(s: DesignerSuggestion): Field[] {
return s.fields.map(f => ({
id: crypto.randomUUID(),
key: f.key,
label: f.label,
control: (['text', 'textArea', 'select', 'dateTime', 'number'].includes(f.control) ? f.control : 'text') as ControlType,
required: f.required,
placeholder: f.placeholder,
options: f.options,
helperText: f.helperText,
}));
}
+12 -3
View File
@@ -4,6 +4,7 @@ import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
import { ProcessBindings } from './ProcessBindings';
import { ProcessDesigner } from './ProcessDesigner';
import { AiDesignerChat, suggestionToFields, type DesignerSuggestion } from './AiDesignerChat';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
@@ -46,11 +47,17 @@ function Designer() {
const [selected, setSelected] = useState(fields[0].id);
const [versions, setVersions] = useState<FormVersion[]>([]);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
const selectedField = fields.find(f => f.id === selected);
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
useEffect(() => { void loadVersions(); }, []);
function applySuggestion(s: DesignerSuggestion) {
if (s.formTitle) setTitle(s.formTitle);
if (s.formKey) setFormKey(s.formKey);
if (s.fields.length) { const newFields = suggestionToFields(s); setFields(newFields); setSelected(newFields[0]?.id ?? ''); }
}
function add(control: ControlType) {
const index = fields.length + 1;
const field: Field = { id: crypto.randomUUID(), key: `field${index}`, label: `字段 ${index}`, control, required: false, ...(control === 'select' ? { options: ['OPTION_1', 'OPTION_2'] } : {}) };
@@ -69,13 +76,15 @@ function Designer() {
}
return <div className="appShell">
<div className="subHeader"><div><strong></strong><span>Schema </span></div><button onClick={save}>稿</button></div>
<div className="subHeader"><div><strong></strong><span>Schema </span></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={save}>稿</button></div></div>
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<section className="meta"><label> Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label><input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
<main className="designer">
<main className={`designer ${showAI ? 'designerWithAI' : ''}`}>
<aside><h2></h2>{palette.map(p => <button className="palette" key={p.control} onClick={() => add(p.control)}> {p.label}</button>)}<h2></h2>{versions.filter(v => v.form_key === formKey).map(v => <div className="version" key={v.version}><span>v{v.version} · {v.status}</span>{v.status === 'DRAFT' && <button onClick={() => publish(v.version)}></button>}</div>)}</aside>
<section className="canvas"><div className="phone"><div className="phoneHeader">{title}</div>{fields.map((f, i) => <div className={`field ${selected === f.id ? 'selected' : ''}`} key={f.id} draggable onDragStart={e => e.dataTransfer.setData('field', f.id)} onDragOver={e => e.preventDefault()} onDrop={e => dropField(e, f.id)} onClick={() => setSelected(f.id)}><label>{f.label}{f.required && ' *'}</label>{f.control === 'textArea' ? <textarea disabled placeholder={f.placeholder} /> : f.control === 'select' ? <select disabled><option>{f.options?.[0] ?? '请选择'}</option></select> : <input disabled placeholder={f.control === 'dateTime' ? '请选择日期和时间' : f.placeholder} />}<div className="fieldActions"><button onClick={e => { e.stopPropagation(); move(f.id, -1); }}></button><button onClick={e => { e.stopPropagation(); move(f.id, 1); }}></button><button onClick={e => { e.stopPropagation(); setFields(fields.filter(x => x.id !== f.id)); }}>×</button></div><small>{i + 1}</small></div>)}</div></section>
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}<details><summary> Schema</summary><pre>{JSON.stringify(schemas, null, 2)}</pre></details></aside>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="表单设计" getCurrentSchema={() => JSON.stringify(schemas)} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify(schemas, null, 2)}</pre></aside>}
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}</aside>
</main>
</div>;
}
+19 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { AiDesignerChat, type DesignerSuggestion } from './AiDesignerChat';
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
@@ -28,6 +29,7 @@ export function ProcessDesigner() {
]);
const [selected, setSelected] = useState(steps[0].id);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
const [history, setHistory] = useState<ProcessHistory[]>([]);
async function loadConfiguration() {
@@ -46,6 +48,18 @@ export function ProcessDesigner() {
const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
function applySuggestion(s: DesignerSuggestion) {
if (s.process) {
const p = s.process;
const m = (['SERIAL', 'PARALLEL', 'CONDITIONAL'].includes(p.mode) ? p.mode : 'SERIAL') as Mode;
setMode(m);
if (p.steps.length) {
const newSteps = p.steps.map(st => ({ id: crypto.randomUUID(), name: st.name, assigneeVariable: (['approverId', 'oaAdministratorId', 'hrReviewerId'].includes(st.assigneeVariable) ? st.assigneeVariable : 'approverId') as Assignee }));
setSteps(newSteps); setSelected(newSteps[0]?.id ?? '');
}
if (p.conditionThresholdDays != null) setThresholdDays(p.conditionThresholdDays);
}
}
function changeMode(next: Mode) {
setMode(next);
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
@@ -79,16 +93,17 @@ export function ProcessDesigner() {
return <section className="processDesignerPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div>
<section className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></section>
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
<div className="processWorkspace">
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div></div>
<section className="processMetaRow"><div className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></div><div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div></section>
<div className={`processWorkspace ${showAI ? 'processWorkspaceWithAI' : ''}`}>
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{rules.find(item => item.variable === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}` : undefined} />}</div>)}</div>
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
</div>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="流程设计" getCurrentSchema={() => JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays })} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays }, null, 2)}</pre></aside>}
<aside className="processProperties"><h2></h2>{mode === 'CONDITIONAL' && <label><input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2></h2>{selectedStep ? <><label><input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label><select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span></span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span></span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small></small></div>}<div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}></button><button onClick={() => move(selectedStep.id, 1)}></button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}></button></div></> : <p></p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}> </button>}{duplicateRestrictedRule && <div className="validationError"></div>}<div className="securityNote"><strong></strong><p></p></div><div className="processHistory"><h2></h2>{history.length === 0 ? <p></p> : history.map(item => <button key={`${item.key}:${item.version}`} onClick={() => loadTemplate(item)}><span><strong>{item.name}</strong><small>{item.key} · v{item.version} · {item.mode}</small></span><b></b></button>)}</div></aside>
</div>
</section>;
+1
View File
@@ -8,6 +8,7 @@ export const userManager = new UserManager({
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
extraQueryParams: { ui_locales: 'zh-CN' },
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
+5 -3
View File
@@ -1,5 +1,5 @@
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[] };
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime' | 'number';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[]; helperText?: string };
export function buildSchemas(title: string, fields: Field[]) {
const properties: Record<string, unknown> = {};
@@ -8,7 +8,9 @@ export function buildSchemas(title: string, fields: Field[]) {
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
: field.control === 'dateTime'
? { type: 'string', format: 'date-time' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
: field.control === 'number'
? { type: 'number' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
}
return {
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
File diff suppressed because one or more lines are too long