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:
@@ -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
@@ -4,6 +4,7 @@ import { userManager } from './auth';
|
|||||||
import { buildSchemas, type ControlType, type Field } from './schema';
|
import { buildSchemas, type ControlType, type Field } from './schema';
|
||||||
import { ProcessBindings } from './ProcessBindings';
|
import { ProcessBindings } from './ProcessBindings';
|
||||||
import { ProcessDesigner } from './ProcessDesigner';
|
import { ProcessDesigner } from './ProcessDesigner';
|
||||||
|
import { AiDesignerChat, suggestionToFields, type DesignerSuggestion } from './AiDesignerChat';
|
||||||
|
|
||||||
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
|
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
|
||||||
const palette: { control: ControlType; label: string }[] = [
|
const palette: { control: ControlType; label: string }[] = [
|
||||||
@@ -46,11 +47,17 @@ function Designer() {
|
|||||||
const [selected, setSelected] = useState(fields[0].id);
|
const [selected, setSelected] = useState(fields[0].id);
|
||||||
const [versions, setVersions] = useState<FormVersion[]>([]);
|
const [versions, setVersions] = useState<FormVersion[]>([]);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [showAI, setShowAI] = useState(false);
|
||||||
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
|
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
|
||||||
const selectedField = fields.find(f => f.id === selected);
|
const selectedField = fields.find(f => f.id === selected);
|
||||||
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
|
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
|
||||||
useEffect(() => { void loadVersions(); }, []);
|
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) {
|
function add(control: ControlType) {
|
||||||
const index = fields.length + 1;
|
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'] } : {}) };
|
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">
|
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>}
|
{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>
|
<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>
|
<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>
|
<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>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { AiDesignerChat, type DesignerSuggestion } from './AiDesignerChat';
|
||||||
|
|
||||||
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
|
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
|
||||||
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
|
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
|
||||||
@@ -28,6 +29,7 @@ export function ProcessDesigner() {
|
|||||||
]);
|
]);
|
||||||
const [selected, setSelected] = useState(steps[0].id);
|
const [selected, setSelected] = useState(steps[0].id);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [showAI, setShowAI] = useState(false);
|
||||||
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
|
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
|
||||||
const [history, setHistory] = useState<ProcessHistory[]>([]);
|
const [history, setHistory] = useState<ProcessHistory[]>([]);
|
||||||
async function loadConfiguration() {
|
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 duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
|
||||||
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
|
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) {
|
function changeMode(next: Mode) {
|
||||||
setMode(next);
|
setMode(next);
|
||||||
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
|
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">
|
return <section className="processDesignerPage">
|
||||||
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
|
{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>
|
<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="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>
|
<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="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 ${showAI ? 'processWorkspaceWithAI' : ''}`}>
|
||||||
<div className="processWorkspace">
|
|
||||||
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
|
<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 />
|
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
|
||||||
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" 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>
|
<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>
|
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
|
||||||
</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>
|
<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>
|
</div>
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const userManager = new UserManager({
|
|||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
|
extraQueryParams: { ui_locales: 'zh-CN' },
|
||||||
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime';
|
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[] };
|
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[]) {
|
export function buildSchemas(title: string, fields: Field[]) {
|
||||||
const properties: Record<string, unknown> = {};
|
const properties: Record<string, unknown> = {};
|
||||||
@@ -8,7 +8,9 @@ export function buildSchemas(title: string, fields: Field[]) {
|
|||||||
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
|
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
|
||||||
: field.control === 'dateTime'
|
: field.control === 'dateTime'
|
||||||
? { type: 'string', format: 'date-time' }
|
? { 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 {
|
return {
|
||||||
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
|
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
+23
-1
@@ -1,13 +1,14 @@
|
|||||||
from fastapi import Depends, FastAPI, HTTPException
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse
|
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse, DesignerSuggestionRequest, DesignerSuggestionResponse
|
||||||
from app.qwen import (
|
from app.qwen import (
|
||||||
QwenConfigurationError,
|
QwenConfigurationError,
|
||||||
QwenSuggestionGateway,
|
QwenSuggestionGateway,
|
||||||
QwenUpstreamError,
|
QwenUpstreamError,
|
||||||
SuggestionGateway,
|
SuggestionGateway,
|
||||||
ProgressGateway,
|
ProgressGateway,
|
||||||
|
DesignerGateway,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="AIOA AI Service", version="0.1.0")
|
app = FastAPI(title="AIOA AI Service", version="0.1.0")
|
||||||
@@ -21,6 +22,10 @@ def get_progress_gateway() -> ProgressGateway:
|
|||||||
return QwenSuggestionGateway(get_settings())
|
return QwenSuggestionGateway(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
def get_designer_gateway() -> DesignerGateway:
|
||||||
|
return QwenSuggestionGateway(get_settings())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
return {"status": "UP"}
|
return {"status": "UP"}
|
||||||
@@ -55,3 +60,20 @@ async def answer_leave_progress(
|
|||||||
except QwenUpstreamError as exc:
|
except QwenUpstreamError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
|
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/designer/suggest", response_model=DesignerSuggestionResponse)
|
||||||
|
async def suggest_design(
|
||||||
|
request: DesignerSuggestionRequest,
|
||||||
|
gateway: DesignerGateway = Depends(get_designer_gateway),
|
||||||
|
) -> DesignerSuggestionResponse:
|
||||||
|
try:
|
||||||
|
suggestion = await gateway.suggest_design(request)
|
||||||
|
except QwenConfigurationError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except QwenUpstreamError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return DesignerSuggestionResponse(
|
||||||
|
suggestion=suggestion,
|
||||||
|
model=get_settings().qwen_model,
|
||||||
|
)
|
||||||
|
|||||||
@@ -65,3 +65,81 @@ class LeaveProgressAnswerRequest(BaseModel):
|
|||||||
class LeaveProgressAnswerResponse(BaseModel):
|
class LeaveProgressAnswerResponse(BaseModel):
|
||||||
answer: str = Field(min_length=1, max_length=2000)
|
answer: str = Field(min_length=1, max_length=2000)
|
||||||
model: str
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 对话式设计助手(多阶段交互) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerStage(StrEnum):
|
||||||
|
UNDERSTANDING = "UNDERSTANDING"
|
||||||
|
CLARIFYING = "CLARIFYING"
|
||||||
|
GENERATING = "GENERATING"
|
||||||
|
VALIDATING = "VALIDATING"
|
||||||
|
CONFIRMING = "CONFIRMING"
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerFieldType(StrEnum):
|
||||||
|
TEXT = "text"
|
||||||
|
TEXT_AREA = "textArea"
|
||||||
|
SELECT = "select"
|
||||||
|
DATE_TIME = "dateTime"
|
||||||
|
NUMBER = "number"
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerFormField(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=64)
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
control: DesignerFieldType
|
||||||
|
required: bool = False
|
||||||
|
placeholder: str | None = Field(default=None, max_length=200)
|
||||||
|
options: list[str] | None = Field(default=None, max_length=20)
|
||||||
|
helperText: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerProcessStep(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
assigneeVariable: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerProcessSuggestion(BaseModel):
|
||||||
|
mode: str = Field(default="SERIAL", max_length=20)
|
||||||
|
steps: list[DesignerProcessStep] = Field(default_factory=list, max_length=10)
|
||||||
|
conditionThresholdDays: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaValidationIssue(BaseModel):
|
||||||
|
field: str = Field(min_length=1, max_length=100)
|
||||||
|
issue: str = Field(min_length=1, max_length=300)
|
||||||
|
severity: str = Field(default="WARNING", max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerSuggestion(BaseModel):
|
||||||
|
stage: DesignerStage = DesignerStage.UNDERSTANDING
|
||||||
|
formTitle: str | None = Field(default=None, max_length=100)
|
||||||
|
formKey: str | None = Field(default=None, max_length=64)
|
||||||
|
fields: list[DesignerFormField] = Field(default_factory=list, max_length=30)
|
||||||
|
process: DesignerProcessSuggestion | None = None
|
||||||
|
summary: str = Field(default="", max_length=500)
|
||||||
|
understanding: str = Field(default="", max_length=800)
|
||||||
|
assumptions: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
needsClarification: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
validationIssues: list[SchemaValidationIssue] = Field(default_factory=list, max_length=20)
|
||||||
|
schemaReady: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ChatTurn(BaseModel):
|
||||||
|
role: str = Field(min_length=1, max_length=20)
|
||||||
|
content: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerSuggestionRequest(BaseModel):
|
||||||
|
message: str = Field(min_length=1, max_length=2000)
|
||||||
|
history: list[ChatTurn] = Field(default_factory=list, max_length=20)
|
||||||
|
currentSchema: str | None = Field(default=None, max_length=8000)
|
||||||
|
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerSuggestionResponse(BaseModel):
|
||||||
|
suggestion: DesignerSuggestion
|
||||||
|
model: str
|
||||||
|
requiresUserConfirmation: bool = True
|
||||||
|
|||||||
+98
-1
@@ -5,7 +5,14 @@ from typing import Protocol
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveProgressAnswerRequest
|
from app.models import (
|
||||||
|
LeaveDraftSuggestion,
|
||||||
|
LeaveDraftSuggestionRequest,
|
||||||
|
LeaveProgressAnswerRequest,
|
||||||
|
DesignerSuggestionRequest,
|
||||||
|
DesignerSuggestion,
|
||||||
|
ChatTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SuggestionGateway(Protocol):
|
class SuggestionGateway(Protocol):
|
||||||
@@ -16,6 +23,10 @@ class ProgressGateway(Protocol):
|
|||||||
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
|
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerGateway(Protocol):
|
||||||
|
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
|
||||||
|
|
||||||
|
|
||||||
class QwenSuggestionGateway:
|
class QwenSuggestionGateway:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -82,6 +93,42 @@ class QwenSuggestionGateway:
|
|||||||
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
|
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
|
||||||
|
|
||||||
|
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion:
|
||||||
|
if not self.settings.qwen_api_key:
|
||||||
|
raise QwenConfigurationError("QWEN_API_KEY is not configured")
|
||||||
|
user_content = json.dumps(
|
||||||
|
{
|
||||||
|
"message": request.message,
|
||||||
|
"history": [t.model_dump() for t in request.history],
|
||||||
|
"currentSchema": request.currentSchema,
|
||||||
|
"timezone": request.timezone,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
messages: list[dict[str, str]] = [{"role": "system", "content": DESIGNER_SYSTEM_PROMPT}]
|
||||||
|
for turn in request.history:
|
||||||
|
messages.append({"role": turn.role, "content": turn.content})
|
||||||
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
payload = {
|
||||||
|
"model": self.settings.qwen_model,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.settings.qwen_base_url}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
content = response.json()["choices"][0]["message"]["content"]
|
||||||
|
return DesignerSuggestion.model_validate_json(content)
|
||||||
|
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
||||||
|
raise QwenUpstreamError("Qwen returned an invalid designer response") from exc
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """
|
SYSTEM_PROMPT = """
|
||||||
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
|
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
|
||||||
@@ -97,6 +144,56 @@ PROGRESS_SYSTEM_PROMPT = """
|
|||||||
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
|
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
DESIGNER_SYSTEM_PROMPT = """
|
||||||
|
你是企业 OA 表单与流程设计助手,通过多阶段对话帮助用户生成正确的表单和审批流程 Schema。
|
||||||
|
|
||||||
|
## 工作流程(5 个阶段)
|
||||||
|
|
||||||
|
1. UNDERSTANDING(理解):分析用户需求,复述你的理解,判断信息是否充分。
|
||||||
|
- 如果信息不足需要澄清 → 进入 CLARIFYING
|
||||||
|
- 如果信息充分 → 直接进入 GENERATING
|
||||||
|
|
||||||
|
2. CLARIFYING(澄清):向用户提出具体问题,每次最多 3 个问题。
|
||||||
|
- 用户回答后重新评估,信息充分则进入 GENERATING
|
||||||
|
|
||||||
|
3. GENERATING(生成):根据理解生成完整的表单字段和审批流程 Schema。
|
||||||
|
- 生成后自动进入 VALIDATING
|
||||||
|
|
||||||
|
4. VALIDATING(校验):自检生成的 Schema 是否正确完整。
|
||||||
|
- 检查项:字段 key 唯一、必填字段合理、控件类型匹配、审批步骤 ≥ 2、assigneeVariable 不重复(PARALLEL/CONDITIONAL)、select 有 options
|
||||||
|
- 有问题则修复后重新校验,无问题则进入 CONFIRMING
|
||||||
|
|
||||||
|
5. CONFIRMING(确认):展示最终 Schema 摘要,请用户确认或调整。
|
||||||
|
- 用户确认 → schemaReady = true
|
||||||
|
- 用户要求调整 → 回到 GENERATING
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
输出必须是 JSON 对象,包含以下字段:
|
||||||
|
- stage:当前阶段(UNDERSTANDING/CLARIFYING/GENERATING/VALIDATING/CONFIRMING)
|
||||||
|
- understanding:你对用户需求的理解复述(中文)
|
||||||
|
- formTitle:表单中文标题
|
||||||
|
- formKey:英文 kebab-case 标识符
|
||||||
|
- fields:数组,每项含 key(英文 camelCase)、label(中文)、control(text/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
|
||||||
|
- process:对象,含 mode(SERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL)
|
||||||
|
- summary:一句话总结
|
||||||
|
- assumptions:你做出的假设
|
||||||
|
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
|
||||||
|
- validationIssues:校验发现的问题数组,每项含 field、issue、severity(ERROR/WARNING)
|
||||||
|
- schemaReady:Schema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true)
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorId(OA 管理员)、hrReviewerId(HR 复核人)
|
||||||
|
- 字段 key 用英文 camelCase 且唯一,label 用中文
|
||||||
|
- 审批流程至少 2 个步骤
|
||||||
|
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
|
||||||
|
- CONDITIONAL 模式需设置 conditionThresholdDays
|
||||||
|
- 不得输出权限、租户、隐藏字段等敏感信息
|
||||||
|
- 不得使用 Markdown
|
||||||
|
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
class QwenConfigurationError(RuntimeError):
|
class QwenConfigurationError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.all8ai.aioa.ai.api
|
||||||
|
|
||||||
|
import com.all8ai.aioa.ai.application.AiDesignerService
|
||||||
|
import com.all8ai.aioa.ai.domain.ChatTurn
|
||||||
|
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai/designer-suggestions")
|
||||||
|
class AiDesignerController(
|
||||||
|
private val currentUserService: CurrentUserService,
|
||||||
|
private val service: AiDesignerService,
|
||||||
|
) {
|
||||||
|
@PostMapping
|
||||||
|
fun suggest(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@Valid @RequestBody request: AiDesignerSuggestionRequest,
|
||||||
|
): DesignerSuggestionResult = service.suggest(
|
||||||
|
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||||
|
request.message,
|
||||||
|
request.history?.map { ChatTurn(it.role, it.content) } ?: emptyList(),
|
||||||
|
request.currentSchema,
|
||||||
|
request.timezone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AiDesignerSuggestionRequest(
|
||||||
|
@field:NotBlank @field:Size(max = 2000) val message: String,
|
||||||
|
val history: List<ChatTurnDto>? = null,
|
||||||
|
@field:Size(max = 8000) val currentSchema: String? = null,
|
||||||
|
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatTurnDto(val role: String, val content: String)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.all8ai.aioa.ai.application
|
||||||
|
|
||||||
|
import com.all8ai.aioa.ai.domain.AiDesignerGateway
|
||||||
|
import com.all8ai.aioa.ai.domain.ChatTurn
|
||||||
|
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AiDesignerService(
|
||||||
|
private val gateway: AiDesignerGateway,
|
||||||
|
private val auditService: AuditService,
|
||||||
|
) {
|
||||||
|
fun suggest(actor: CurrentUser, message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult {
|
||||||
|
actor.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT)
|
||||||
|
val normalized = message.trim()
|
||||||
|
if (normalized.isEmpty() || normalized.length > 2000) {
|
||||||
|
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
|
||||||
|
}
|
||||||
|
if (timezone.isBlank() || timezone.length > 64) {
|
||||||
|
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
|
||||||
|
}
|
||||||
|
val result = gateway.suggest(normalized, history, currentSchema?.takeIf { it.isNotBlank() }, timezone)
|
||||||
|
if (!result.requiresUserConfirmation) {
|
||||||
|
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
|
||||||
|
}
|
||||||
|
auditService.recordSuccess(
|
||||||
|
actor,
|
||||||
|
"AI_DESIGNER_SUGGESTED",
|
||||||
|
"AI_SUGGESTION",
|
||||||
|
"designer",
|
||||||
|
null,
|
||||||
|
mapOf(
|
||||||
|
"model" to result.model,
|
||||||
|
"promptLength" to normalized.length,
|
||||||
|
"fieldCount" to result.suggestion.fields.size,
|
||||||
|
"hasProcess" to (result.suggestion.process != null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.all8ai.aioa.ai.domain
|
||||||
|
|
||||||
|
data class DesignerFormField(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
val control: String,
|
||||||
|
val required: Boolean = false,
|
||||||
|
val placeholder: String? = null,
|
||||||
|
val options: List<String>? = null,
|
||||||
|
val helperText: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesignerProcessStep(
|
||||||
|
val name: String,
|
||||||
|
val assigneeVariable: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesignerProcessSuggestion(
|
||||||
|
val mode: String = "SERIAL",
|
||||||
|
val steps: List<DesignerProcessStep> = emptyList(),
|
||||||
|
val conditionThresholdDays: Double? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SchemaValidationIssue(
|
||||||
|
val field: String,
|
||||||
|
val issue: String,
|
||||||
|
val severity: String = "WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesignerSuggestion(
|
||||||
|
val stage: String = "UNDERSTANDING",
|
||||||
|
val formTitle: String? = null,
|
||||||
|
val formKey: String? = null,
|
||||||
|
val fields: List<DesignerFormField> = emptyList(),
|
||||||
|
val process: DesignerProcessSuggestion? = null,
|
||||||
|
val summary: String = "",
|
||||||
|
val understanding: String = "",
|
||||||
|
val assumptions: List<String> = emptyList(),
|
||||||
|
val needsClarification: List<String> = emptyList(),
|
||||||
|
val validationIssues: List<SchemaValidationIssue> = emptyList(),
|
||||||
|
val schemaReady: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesignerSuggestionResult(
|
||||||
|
val suggestion: DesignerSuggestion,
|
||||||
|
val model: String,
|
||||||
|
val requiresUserConfirmation: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatTurn(
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun interface AiDesignerGateway {
|
||||||
|
fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.all8ai.aioa.ai.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.ai.domain.AiDesignerGateway
|
||||||
|
import com.all8ai.aioa.ai.domain.ChatTurn
|
||||||
|
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
import org.springframework.web.client.RestClientException
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class HttpAiDesignerGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiDesignerGateway {
|
||||||
|
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
|
||||||
|
|
||||||
|
override fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult = try {
|
||||||
|
client.post()
|
||||||
|
.uri("/v1/designer/suggest")
|
||||||
|
.body(DesignerRequest(message, history, currentSchema, timezone))
|
||||||
|
.retrieve()
|
||||||
|
.body(DesignerSuggestionResult::class.java)
|
||||||
|
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回设计建议")
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: RestClientException) {
|
||||||
|
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DesignerRequest(val message: String, val history: List<ChatTurn>, val currentSchema: String?, val timezone: String)
|
||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
- "8081:8080"
|
- "8081:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro
|
- ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro
|
||||||
|
- ./keycloak/themes/aioa:/opt/keycloak/themes/aioa:ro
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
|
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
"realm": "aioa",
|
"realm": "aioa",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"displayName": "AIOA Development",
|
"displayName": "AIOA 智能办公自动化平台",
|
||||||
"registrationAllowed": false,
|
"registrationAllowed": false,
|
||||||
"resetPasswordAllowed": true,
|
"resetPasswordAllowed": true,
|
||||||
"loginWithEmailAllowed": true,
|
"loginWithEmailAllowed": true,
|
||||||
|
"loginTheme": "aioa",
|
||||||
|
"internationalizationEnabled": true,
|
||||||
|
"defaultLocale": "zh_CN",
|
||||||
|
"supportedLocales": ["zh-CN", "zh_CN", "en", "en-US"],
|
||||||
"roles": {
|
"roles": {
|
||||||
"realm": [
|
"realm": [
|
||||||
{ "name": "employee", "description": "普通员工" },
|
{ "name": "employee", "description": "普通员工" },
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# AIOA 自定义中文消息
|
||||||
|
# 登录页
|
||||||
|
loginTitle=AIOA 管理平台登录
|
||||||
|
loginTitleHtml=AIOA 管理平台
|
||||||
|
loginSubtitle=请使用您的账号登录
|
||||||
|
|
||||||
|
doLogIn=登 录
|
||||||
|
doRegister=注册
|
||||||
|
doCancel=取消
|
||||||
|
doSubmit=提交
|
||||||
|
doBack=返回
|
||||||
|
doYes=是
|
||||||
|
doNo=否
|
||||||
|
doContinue=继续
|
||||||
|
doIgnore=忽略
|
||||||
|
doAccept=接受
|
||||||
|
doDecline=拒绝
|
||||||
|
doForgotPassword=忘记密码?
|
||||||
|
doClickHere=点击这里
|
||||||
|
doTryAgain=重试
|
||||||
|
doTryAnotherWay=尝试其他方式
|
||||||
|
doConfirmDelete=确认删除
|
||||||
|
|
||||||
|
# 账号字段
|
||||||
|
username=用户名
|
||||||
|
usernameOrEmail=用户名或邮箱
|
||||||
|
password=密码
|
||||||
|
passwordConfirm=确认密码
|
||||||
|
passwordNew=新密码
|
||||||
|
passwordConfirmNew=再次输入新密码
|
||||||
|
email=邮箱
|
||||||
|
firstName=名
|
||||||
|
lastName=姓
|
||||||
|
rememberMe=记住我
|
||||||
|
|
||||||
|
# 提示信息
|
||||||
|
loginTotpTitle=移动认证器应用
|
||||||
|
loginTotpMessage=请输入验证码
|
||||||
|
loginTotpStep1=第 1 步:在认证器应用中添加此账号
|
||||||
|
loginTotpStep2=第 2 步:输入验证码
|
||||||
|
loginTotpStep3DeviceName=请为您的设备命名(可选)
|
||||||
|
loginTotpManualStep2=请输入以下密钥:
|
||||||
|
loginTotpScanBarcode=扫描二维码
|
||||||
|
loginTotpOneTimeCode=验证码
|
||||||
|
loginTotp.totp=基于时间(TOTP)
|
||||||
|
loginTotp.hotp=基于计数器(HOTP)
|
||||||
|
|
||||||
|
# 忘记密码
|
||||||
|
emailForgotTitle=忘记密码
|
||||||
|
emailForgotInstructions=请输入您的用户名或邮箱,我们将发送重置密码的链接。
|
||||||
|
|
||||||
|
# 错误信息
|
||||||
|
invalidUserMessage=用户名或密码错误
|
||||||
|
invalidPasswordMessage=密码错误
|
||||||
|
invalidEmailMessage=请输入有效的邮箱地址
|
||||||
|
invalidUsernameMessage=用户名不存在
|
||||||
|
invalidUsernameOrEmailMessage=用户名或邮箱不存在
|
||||||
|
accountDisabledMessage=账号已被禁用
|
||||||
|
accountTemporarilyDisabledMessage=账号已被临时锁定
|
||||||
|
expiredCodeMessage=登录超时,请重试
|
||||||
|
expiredActionMessage=操作超时,请重试
|
||||||
|
sessionLimitExceeded=会话数超限
|
||||||
|
identityProviderDisabledMessage=身份提供者已被禁用
|
||||||
|
identityProviderAlreadyLinkedMessage=该第三方账号已绑定其他用户
|
||||||
|
identityProviderNotFoundMessage=无法连接到身份提供者
|
||||||
|
emailNotFoundMessage=未找到与该邮箱关联的账号
|
||||||
|
federatedIdentityLinkMessage=请验证您的邮箱以关联账号
|
||||||
|
federatedIdentityUnlinkSuccessMsg=已成功解除第三方账号关联
|
||||||
|
confirmAccountLinkingMsg=确认关联您的账号 {0} 与 {1}?
|
||||||
|
confirmEmailAddress=请确认您的邮箱地址
|
||||||
|
confirmExecutionDescription=确认执行操作
|
||||||
|
|
||||||
|
# 信息提示
|
||||||
|
emailSendInstructions=重置密码邮件已发送
|
||||||
|
emailSendAlreadyInstructions=重置密码邮件已发送
|
||||||
|
updatePasswordTitle=更新密码
|
||||||
|
updatePasswordMessage=请设置您的新密码
|
||||||
|
updatePasswordMessageTwo=请确认您的新密码
|
||||||
|
|
||||||
|
# 页脚
|
||||||
|
termsText=登录即表示您同意我们的服务条款
|
||||||
|
privacyPolicy=隐私政策
|
||||||
|
termsOfService=服务条款
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
requiredMessage=此字段为必填
|
||||||
|
browserLogin=浏览器登录
|
||||||
|
otherMethods=其他登录方式
|
||||||
|
grantInformation=请确认授权以下权限
|
||||||
|
scopes=权限范围
|
||||||
|
|
||||||
|
# OTP 验证
|
||||||
|
loginTotpIntro=为了您的账号安全,请配置两步验证
|
||||||
|
|
||||||
|
# 信息页面
|
||||||
|
infoTitle=提示
|
||||||
|
infoMessage=请按照说明操作
|
||||||
|
|
||||||
|
# 登出
|
||||||
|
logoutTitle=您已登出
|
||||||
|
logoutSignOutIn=您已成功登出,可以重新登录
|
||||||
|
backToApplication=返回应用
|
||||||
|
|
||||||
|
# 会话超时
|
||||||
|
sessionTimeoutMsg=您的会话已超时,请重新登录
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* AIOA 自定义登录主题样式
|
||||||
|
* 适配 Keycloak v2 主题 (PatternFly v5)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--pf-v5-global--primary-color--100: #2563eb;
|
||||||
|
--pf-v5-global--primary-color--200: #1d4ed8;
|
||||||
|
--pf-v5-global--BackgroundColor--100: #f0f4f8;
|
||||||
|
--aioa-gradient-start: #1e3a8a;
|
||||||
|
--aioa-gradient-end: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 整体页面背景 */
|
||||||
|
.login-pf body,
|
||||||
|
body.pf-v5-c-login {
|
||||||
|
background: linear-gradient(135deg, var(--aioa-gradient-start) 0%, var(--aioa-gradient-end) 100%) !important;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录卡片 */
|
||||||
|
.pf-v5-c-login__main {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__main-header {
|
||||||
|
padding: 40px 36px 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__main-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__main-header p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__main-body {
|
||||||
|
padding: 28px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单输入框 */
|
||||||
|
.pf-v5-c-form__group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-form-control {
|
||||||
|
border: 1.5px solid #cbd5e1 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-form-control:focus {
|
||||||
|
border-color: #2563eb !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15) !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录按钮 */
|
||||||
|
.pf-v5-c-button.pf-m-primary {
|
||||||
|
background: linear-gradient(135deg, #1e3a8a, #2563eb) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 12px 24px !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-button.pf-m-primary:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-button.pf-m-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 次要链接 */
|
||||||
|
.pf-v5-c-login__main-footer a,
|
||||||
|
.pf-v5-c-form__helper-text a {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__main-footer a:hover,
|
||||||
|
.pf-v5-c-form__helper-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示信息 */
|
||||||
|
.pf-v5-c-alert {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.pf-v5-c-login__footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pf-v5-c-login__main {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.pf-v5-c-login__main-header,
|
||||||
|
.pf-v5-c-login__main-body {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// AIOA 开发环境:预填充测试管理员凭据
|
||||||
|
(function () {
|
||||||
|
function prefill() {
|
||||||
|
var usernameInput = document.getElementById('username');
|
||||||
|
var passwordInput = document.getElementById('password');
|
||||||
|
if (usernameInput && !usernameInput.value) {
|
||||||
|
usernameInput.value = 'admin';
|
||||||
|
}
|
||||||
|
if (passwordInput && !passwordInput.value) {
|
||||||
|
passwordInput.value = 'Admin123!';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', prefill);
|
||||||
|
} else {
|
||||||
|
prefill();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
parent=keycloak.v2
|
||||||
|
import=common/keycloak
|
||||||
|
|
||||||
|
# Styles generated by the server
|
||||||
|
styles=css/login.css
|
||||||
|
scripts=js/prefill.js
|
||||||
|
|
||||||
|
# Meta tags
|
||||||
|
meta=viewport==width=device-width,initial-scale=1.0
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
displayName=AIOA 登录
|
||||||
Reference in New Issue
Block a user