feat: add web form designer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 后按流水线文件末尾的说明启用。
|
||||
|
||||
@@ -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 容器或通过管理控制台补入同名客户端。
|
||||
@@ -0,0 +1 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
Generated
+2959
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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])) } : {}) })) }] },
|
||||
};
|
||||
}
|
||||
@@ -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}}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
- [x] Flutter 管理工具入口与权限驱动展示
|
||||
- [x] 业务与推送运行指标仪表板
|
||||
- [x] 表单版本发布、业务流程绑定与服务端路由选择
|
||||
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
|
||||
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
Reference in New Issue
Block a user