feat: add web form designer

This commit is contained in:
selfrelease
2026-07-18 22:24:52 +08:00
parent 15abd8a32f
commit 342a1fcb33
22 changed files with 3273 additions and 0 deletions
+5
View File
@@ -34,3 +34,8 @@ firebase-service-account*.json
.local/
coverage/
reports/
node_modules/
admin-web/dist/
*.tsbuildinfo
admin-web/vite.config.js
admin-web/vite.config.d.ts
+20
View File
@@ -92,6 +92,26 @@ flutter-verify:
paths:
- mobile/reports/flutter-test.json
admin-web-verify:
stage: verify
image: node:24-alpine
cache:
key:
files:
- admin-web/package-lock.json
paths:
- .cache/npm/
before_script:
- cd admin-web
- npm ci --cache ../.cache/npm
script:
- npm test
- npm run build
artifacts:
expire_in: 7 days
paths:
- admin-web/dist/
android-debug-build:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
+4
View File
@@ -19,6 +19,7 @@ AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实
```text
backend/ Kotlin + Spring Boot 模块化单体
mobile/ Flutter 移动客户端
admin-web/ React + TypeScript 表单设计与流程配置管理端
ai-service/ Python AI 服务
contracts/ OpenAPI 与事件契约
deploy/ 本地与部署配置
@@ -71,6 +72,8 @@ AIOA_FULL_BUILD=1 ./scripts/verify-all.sh
Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engineering/mobile-schema-forms.md](docs/engineering/mobile-schema-forms.md)。
Web 管理端已实现可视化表单设计、实时移动卡片预览和版本发布。运行与验证方式见 [admin-web/README.md](admin-web/README.md)。
## 持续集成
仓库根目录的 `.gitlab-ci.yml` 默认执行:
@@ -78,6 +81,7 @@ Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engine
- JDK 21 后端测试,并上传 JUnit 报告;
- Python 3.12 AI 服务编译检查与测试;
- Flutter 格式检查、静态分析和测试;
- React 管理端单元测试和生产构建;
- Android Debug APK 构建与产物归档。
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
+40
View File
@@ -0,0 +1,40 @@
# AIOA 管理设计中心
React + TypeScript 管理端,当前提供可视化表单设计器 MVP:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
## 本地运行
先从仓库根目录启动 PostgreSQL、Keycloak 和后端:
```bash
docker compose --env-file .env.example -f deploy/compose/compose.yaml up -d postgres keycloak
cd backend
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
export GRADLE_USER_HOME=/tmp/aioa-gradle-home
./gradlew --no-daemon :boot:bootRun
```
再启动管理端:
```bash
cd admin-web
npm ci
npm run dev -- --host 127.0.0.1
```
访问 `http://127.0.0.1:5173`,通过 Keycloak 登录。默认 OIDC Issuer 为 `http://localhost:8081/realms/aioa`,后端 API 为 `http://localhost:8080/api/v1`;需要覆盖时可设置:
```bash
VITE_OIDC_ISSUER=http://localhost:8081/realms/aioa
VITE_API_BASE_URL=http://localhost:8080/api/v1
```
## 验证
```bash
npm test
npm run build
```
Keycloak Realm 首次导入时会创建 `aioa-admin-web` 公共客户端并启用 Authorization Code + PKCE S256。若本机 Keycloak 已在加入该客户端之前启动,需要在开发环境重建 Keycloak 容器或通过管理控制台补入同名客户端。
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+2959
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "aioa-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"oidc-client-ts": "^3.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"jsdom": "^26.1.0",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vitest": "^3.2.4"
}
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
{ control: 'text', label: '单行文本' }, { control: 'textArea', label: '多行文本' },
{ control: 'select', label: '下拉选择' }, { control: 'dateTime', label: '日期时间' },
];
export function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (location.pathname === '/callback') { await userManager.signinRedirectCallback(); history.replaceState({}, '', '/'); }
const user = await userManager.getUser(); setAuthenticated(Boolean(user && !user.expired)); setLoading(false);
})().catch(() => setLoading(false));
}, []);
if (loading) return <div className="center"></div>;
if (!authenticated) return <Login />;
return <Designer onLogout={() => userManager.signoutRedirect()} />;
}
function Login() {
return <main className="login"><div className="brandMark">A</div><h1>AIOA </h1><p> Flowable </p><button onClick={() => userManager.signinRedirect()}>使 Keycloak </button></main>;
}
function Designer({ onLogout }: { onLogout: () => void }) {
const [formKey, setFormKey] = useState('leave-request');
const [title, setTitle] = useState('请假申请');
const [fields, setFields] = useState<Field[]>([
{ id: crypto.randomUUID(), key: 'type', label: '请假类型', control: 'select', required: true, options: ['PERSONAL', 'SICK', 'ANNUAL'] },
{ id: crypto.randomUUID(), key: 'startsAt', label: '开始时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'endsAt', label: '结束时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'reason', label: '请假原因', control: 'textArea', required: true },
]);
const [selected, setSelected] = useState(fields[0].id);
const [versions, setVersions] = useState<FormVersion[]>([]);
const [message, setMessage] = useState('');
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 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'] } : {}) };
setFields([...fields, field]); setSelected(field.id);
}
function update(patch: Partial<Field>) { setFields(fields.map(f => f.id === selected ? { ...f, ...patch } : f)); }
function move(id: string, delta: number) { const from = fields.findIndex(f => f.id === id), to = from + delta; if (to < 0 || to >= fields.length) return; const next = [...fields]; [next[from], next[to]] = [next[to], next[from]]; setFields(next); }
function dropField(event: React.DragEvent, targetId: string) { event.preventDefault(); const sourceId = event.dataTransfer.getData('field'); const source = fields.findIndex(f => f.id === sourceId), target = fields.findIndex(f => f.id === targetId); if (source < 0 || target < 0) return; const next = [...fields]; const [item] = next.splice(source, 1); next.splice(target, 0, item); setFields(next); }
async function save() {
try { const result = await api<{ version: number }>('/admin/process-configuration/forms', { method: 'POST', body: JSON.stringify({ formKey, ...schemas }) }); setMessage(`草稿 v${result.version} 已保存`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '保存失败'); }
}
async function publish(version: number) {
try { await api(`/admin/process-configuration/forms/${formKey}/${version}/publish`, { method: 'POST' }); setMessage(`v${version} 已发布`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '发布失败'); }
}
return <div className="appShell">
<header><div><strong>AIOA</strong><span></span></div><div className="headerActions"><button className="ghost" onClick={onLogout}>退</button><button onClick={save}>稿</button></div></header>
{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">
<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>
</main>
</div>;
}
+30
View File
@@ -0,0 +1,30 @@
import { accessToken } from './auth';
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api/v1';
const deviceId = localStorage.getItem('aioa.admin.device') ?? crypto.randomUUID();
localStorage.setItem('aioa.admin.device', deviceId);
let registeredToken = '';
async function ensureDevice(token: string) {
if (registeredToken === token) return;
const response = await fetch(`${baseUrl}/devices/register`, {
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deviceId, name: navigator.userAgent.slice(0, 200), platform: 'OTHER', appVersion: 'admin-web' }),
});
if (!response.ok) throw new Error(`设备注册失败 (${response.status})`);
registeredToken = token;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await accessToken();
if (!token) throw new Error('登录已失效');
await ensureDevice(token);
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, 'X-AIOA-Device-Id': deviceId, 'Content-Type': 'application/json', ...init.headers },
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.detail ?? `请求失败 (${response.status})`);
if (response.status === 204 || response.headers.get('content-length') === '0') return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
+17
View File
@@ -0,0 +1,17 @@
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const issuer = import.meta.env.VITE_OIDC_ISSUER ?? 'http://localhost:8081/realms/aioa';
export const userManager = new UserManager({
authority: issuer,
client_id: 'aioa-admin-web',
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
export async function accessToken(): Promise<string | null> {
const user = await userManager.getUser();
return user && !user.expired ? user.access_token : null;
}
+5
View File
@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildSchemas } from './schema';
describe('buildSchemas', () => {
it('builds required fields and safe controls', () => {
const result = buildSchemas('请假申请', [{ id: '1', key: 'reason', label: '原因', control: 'textArea', required: true }]);
expect(result.dataSchema.required).toEqual(['reason']);
expect(result.uiSchema.sections[0].controls[0].control).toBe('textArea');
});
});
+17
View File
@@ -0,0 +1,17 @@
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 function buildSchemas(title: string, fields: Field[]) {
const properties: Record<string, unknown> = {};
for (const field of fields) {
properties[field.key] = field.control === 'select'
? { 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 };
}
return {
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
uiSchema: { description: `${title} · 由 AIOA 表单设计器生成`, sections: [{ title: '基本信息', controls: fields.map(f => ({ field: f.key, label: f.label, control: f.control, placeholder: f.placeholder, ...(f.control === 'select' ? { optionLabels: Object.fromEntries((f.options ?? []).map(v => [v, v])) } : {}) })) }] },
};
}
+1
View File
@@ -0,0 +1 @@
:root{font-family:Inter,"PingFang SC",system-ui,sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{margin:0}button{border:0;border-radius:10px;background:#425aa5;color:white;padding:10px 14px;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}input,textarea,select{width:100%;border:1px solid #d7dce8;border-radius:9px;padding:10px;background:white;font:inherit}.center,.login{min-height:100vh;display:grid;place-content:center;text-align:center;gap:12px}.login{max-width:460px;margin:auto}.brandMark{width:72px;height:72px;border-radius:22px;display:grid;place-content:center;margin:auto;background:#dce3ff;color:#304887;font-size:36px;font-weight:900}header{height:68px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}header strong{font-size:24px;margin-right:16px}header span{color:#6e7688}.headerActions{display:flex;gap:8px}.ghost{background:#eef1f7;color:#334}.meta{display:flex;gap:18px;padding:16px 24px;background:#fff}.meta label{display:grid;grid-template-columns:auto 230px;gap:8px;align-items:center}.designer{display:grid;grid-template-columns:220px minmax(380px,1fr) 300px;min-height:calc(100vh - 134px)}aside{padding:18px;background:#fff;border-right:1px solid #e3e6ee}aside h2{font-size:15px;margin:10px 0}.palette{display:block;width:100%;text-align:left;margin-bottom:8px;background:#eef2ff;color:#304887}.canvas{padding:28px;overflow:auto}.phone{width:390px;min-height:690px;margin:auto;background:#f9fafc;border:10px solid #202636;border-radius:40px;padding:26px 18px;box-shadow:0 20px 50px #29304a26}.phoneHeader{text-align:center;font-size:20px;font-weight:800;margin-bottom:22px}.field{position:relative;background:white;border:2px solid transparent;border-radius:14px;padding:14px;margin-bottom:12px;box-shadow:0 3px 12px #26304a12}.field.selected{border-color:#5c70bb}.field label{display:block;font-size:13px;font-weight:700;margin-bottom:8px}.fieldActions{position:absolute;right:7px;top:6px;display:none;gap:3px}.field:hover .fieldActions,.field.selected .fieldActions{display:flex}.fieldActions button{padding:3px 7px;border-radius:6px}.field small{position:absolute;left:-9px;top:-9px;background:#425aa5;color:white;border-radius:50%;width:20px;height:20px;text-align:center}.properties{border:0;border-left:1px solid #e3e6ee}.properties label{display:block;margin-bottom:14px;font-size:13px;font-weight:650}.properties .check{display:flex;gap:8px;align-items:center}.properties .check input{width:auto}.properties textarea{min-height:110px}.properties pre{font-size:10px;white-space:pre-wrap;background:#151b2b;color:#dce6ff;padding:10px;border-radius:8px;max-height:260px;overflow:auto}.version{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee;font-size:12px}.version button{padding:5px 8px}.toast{position:fixed;z-index:10;top:78px;left:50%;transform:translateX(-50%);background:#172033;color:white;padding:10px 18px;border-radius:10px}@media(max-width:1000px){.designer{grid-template-columns:180px 1fr}.properties{grid-column:1/-1}.meta{flex-direction:column}.meta label{grid-template-columns:100px 1fr}}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
"strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
"resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx"
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
"include": ["vite.config.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom' }, server: { port: 5173 } });
@@ -5,16 +5,31 @@ import org.springframework.context.annotation.Configuration
import org.springframework.security.config.Customizer.withDefaults
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
class SecurityConfiguration {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.csrf { it.disable() }
.cors(withDefaults())
.authorizeHttpRequests {
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt(withDefaults()) }
.build()
@Bean
fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", CorsConfiguration().apply {
allowedOrigins = listOf("http://localhost:5173", "http://127.0.0.1:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Authorization", "Content-Type", "X-AIOA-Device-Id", "Idempotency-Key", "X-Trace-Id")
exposedHeaders = listOf("X-Trace-Id", "X-AIOA-Auth-Error")
allowCredentials = true
})
}
}
+27
View File
@@ -42,6 +42,33 @@
}
}
]
},
{
"clientId": "aioa-admin-web",
"name": "AIOA Admin Web",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:5173/*", "http://127.0.0.1:5173/*"],
"webOrigins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"attributes": { "pkce.code.challenge.method": "S256" },
"protocolMappers": [
{
"name": "tenant-id",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "tenant_id",
"claim.name": "tenant_id",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
+2
View File
@@ -56,6 +56,8 @@
- [x] Flutter 管理工具入口与权限驱动展示
- [x] 业务与推送运行指标仪表板
- [x] 表单版本发布、业务流程绑定与服务端路由选择
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
## Definition of Done