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 { 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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
+23
-1
@@ -1,13 +1,14 @@
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
|
||||
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 (
|
||||
QwenConfigurationError,
|
||||
QwenSuggestionGateway,
|
||||
QwenUpstreamError,
|
||||
SuggestionGateway,
|
||||
ProgressGateway,
|
||||
DesignerGateway,
|
||||
)
|
||||
|
||||
app = FastAPI(title="AIOA AI Service", version="0.1.0")
|
||||
@@ -21,6 +22,10 @@ def get_progress_gateway() -> ProgressGateway:
|
||||
return QwenSuggestionGateway(get_settings())
|
||||
|
||||
|
||||
def get_designer_gateway() -> DesignerGateway:
|
||||
return QwenSuggestionGateway(get_settings())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "UP"}
|
||||
@@ -55,3 +60,20 @@ async def answer_leave_progress(
|
||||
except QwenUpstreamError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
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):
|
||||
answer: str = Field(min_length=1, max_length=2000)
|
||||
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
|
||||
|
||||
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):
|
||||
@@ -16,6 +23,10 @@ class ProgressGateway(Protocol):
|
||||
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
|
||||
|
||||
|
||||
class DesignerGateway(Protocol):
|
||||
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
|
||||
|
||||
|
||||
class QwenSuggestionGateway:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
@@ -82,6 +93,42 @@ class QwenSuggestionGateway:
|
||||
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as 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 = """
|
||||
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
|
||||
@@ -97,6 +144,56 @@ PROGRESS_SYSTEM_PROMPT = """
|
||||
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
|
||||
""".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):
|
||||
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"
|
||||
volumes:
|
||||
- ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro
|
||||
- ./keycloak/themes/aioa:/opt/keycloak/themes/aioa:ro
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"realm": "aioa",
|
||||
"enabled": true,
|
||||
"displayName": "AIOA Development",
|
||||
"displayName": "AIOA 智能办公自动化平台",
|
||||
"registrationAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"loginWithEmailAllowed": true,
|
||||
"loginTheme": "aioa",
|
||||
"internationalizationEnabled": true,
|
||||
"defaultLocale": "zh_CN",
|
||||
"supportedLocales": ["zh-CN", "zh_CN", "en", "en-US"],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "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