feat: add workflow orchestration and media governance

This commit is contained in:
xz
2026-08-24 22:36:43 +08:00
parent 9811469a64
commit aa0e0be27a
16 changed files with 775 additions and 13 deletions
+39 -2
View File
@@ -44,7 +44,8 @@ import {
Webhook,
Workflow,
XCircle,
Zap
Zap,
Trash2
} from "lucide-react";
import {
createApiClient,
@@ -90,6 +91,8 @@ import {
testNotification,
fetchNotificationDeliveries,
fetchStorageUsage,
fetchStorageCleanupPreview,
reclaimStorage,
fetchSsoProviders,
fetchIdentityCenter,
fetchOrganizationCommercial,
@@ -2460,6 +2463,7 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
const [health, setHealth] = useState({ services: [], summary: {} });
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
const [storage, setStorage] = useState(null);
const [cleanup, setCleanup] = useState(null);
const [deliveries, setDeliveries] = useState([]);
const [originalSettings, setOriginalSettings] = useState({});
const [loading, setLoading] = useState(true);
@@ -2468,17 +2472,19 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
const [clientName, setClientName] = useState("");
const [apiKeyNotice, setApiKeyNotice] = useState("");
const [backupBusy, setBackupBusy] = useState(false);
const [cleanupBusy, setCleanupBusy] = useState(false);
async function load() {
setLoading(true);
try {
const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides)]);
const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries, nextCleanup] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides), fetchStorageCleanupPreview("", contextOverrides)]);
setConfig(nextConfig);
const values = Object.fromEntries(nextConfig.settings.map((setting) => [setting.key, setting.value]));
setOriginalSettings(values);
setHealth(nextHealth);
setReadiness(nextReadiness);
setStorage(nextStorage.storage || null);
setCleanup(nextCleanup.cleanup || null);
setDeliveries(nextDeliveries.deliveries || []);
setNotice("");
} catch (error) {
@@ -2572,6 +2578,36 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
}
}
async function previewCleanup() {
setCleanupBusy(true);
try {
const retentionDays = config.settings.find((setting) => setting.key === "storage.retention_days")?.value || "";
const result = await fetchStorageCleanupPreview(retentionDays, contextOverrides);
setCleanup(result.cleanup || null);
setNotice(`清理预览完成:${result.cleanup?.candidates?.length || 0} 个无引用临时文件,${((result.cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB。`);
} catch (error) {
setNotice(error.message);
} finally {
setCleanupBusy(false);
}
}
async function reclaimCleanup() {
if (!cleanup?.candidates?.length) return;
if (typeof window !== "undefined" && !window.confirm(`确认回收 ${cleanup.candidates.length} 个无引用临时文件吗?角色资产、交付物和 QA 证据不会被处理。`)) return;
setCleanupBusy(true);
try {
const result = await reclaimStorage({ paths: cleanup.candidates.map((item) => item.relativePath) }, contextOverrides);
setCleanup(result.cleanup || null);
setStorage(result.storage || storage);
setNotice(`已回收 ${result.cleanup?.deleted?.length || 0} 个临时文件,释放 ${((result.cleanup?.deletedBytes || 0) / 1024 ** 2).toFixed(2)} MB,并写入审计日志。`);
} catch (error) {
setNotice(error.message);
} finally {
setCleanupBusy(false);
}
}
return (
<div className="enterprise-page">
<AdminHeader eyebrow="SYSTEM SETTINGS / CONTROL PLANE" title={title} description={description} action={<div className="header-actions"><button className="subtle" onClick={load}><RefreshCw size={15} />刷新</button>{section !== "overview" && <button className="primary" onClick={save} disabled={saving || !dirtySettings.length}><Save size={15} />{saving ? "保存中…" : dirtySettings.length ? `保存 ${dirtySettings.length} 项` : "保存配置"}</button>}</div>} />
@@ -2583,6 +2619,7 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
{(section !== "notifications" && section !== "overview" && section !== "features") && <section className="studio-card wide-card"><SectionBar title="系统配置" detail={dirtySettings.length ? `${dirtySettings.length} 项未保存` : "已同步"} action={<span className="setting-scope"><Database size={14} />SQLite / system_settings</span>} /><div className="settings-list">{visibleSettings.map((setting) => <div className="system-setting-row" key={setting.key}><div><strong>{settingLabels[setting.category] || setting.category} · {setting.key}</strong><span>{setting.description}</span></div><SettingInput setting={setting} value={setting.value} onChange={(value) => setSetting(setting.key, value)} /></div>)}</div></section>}
{(section === "overview" || section === "generation" || section === "deployment" || section === "features") && <section className="studio-card wide-card"><SectionBar title="功能开关" detail="系统范围,变更立即生效" /><div className="feature-flag-grid">{config.featureFlags.map((flag) => <div key={flag.key}><div><strong>{flag.label}</strong><span>{flag.description}</span></div><button type="button" className={`switch-control ${flag.enabled ? "on" : ""}`} onClick={() => toggleFlag(flag)}><span />{flag.enabled ? "启用" : "关闭"}</button></div>)}</div></section>}
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="真实存储用量" detail={storage ? `当前项目 ${storage.percent}%` : "读取中"} /><div className="storage-admin-grid"><div className="storage-admin-kpi"><HardDrive size={20} /><strong>{storage ? `${storage.usedGb} GB` : "—"}</strong><span>已使用 / {storage?.limitGb || 0} GB</span></div><div className="storage-admin-track"><div><span>文件系统测量</span><b>{storage ? `${storage.usedBytes.toLocaleString()} / ${storage.limitBytes.toLocaleString()} bytes` : "—"}</b></div><div className="quota-track"><span style={{ width: `${storage?.percent || 0}%` }} /></div><small>资产、任务结果和本地合成输出均计入存储配额。</small></div><div className="storage-admin-files"><span>最大文件</span>{storage?.largestFiles?.slice(0, 4).map((file) => <div key={file.path}><strong>{file.relativePath}</strong><em>{(file.bytes / 1024 ** 2).toFixed(2)} MB</em></div>) || <small>暂无文件</small>}</div></div></section>}
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="临时媒体治理" detail={cleanup ? `保留 ${cleanup.policy.retentionDays} 天 · ${cleanup.candidates.length} 个候选` : "只处理无引用临时文件"} action={<div className="header-actions"><button className="subtle" onClick={previewCleanup} disabled={cleanupBusy}><RefreshCw size={14} />{cleanupBusy ? "检查中…" : "生成清理预览"}</button>{cleanup?.candidates?.length > 0 && <button className="danger-button" onClick={reclaimCleanup} disabled={cleanupBusy}><Trash2 size={14} />回收候选文件</button>}</div>} /><div className="storage-cleanup-summary"><div><strong>{cleanup?.candidates?.length || 0}</strong><span>无引用临时文件</span></div><div><strong>{((cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB</strong><span>预计可释放</span></div><div><strong>{cleanup?.policy?.provider || "local-filesystem"}</strong><span>存储提供方</span></div><div><strong>{cleanup?.cutoff ? cleanup.cutoff.slice(0, 10) : "—"}</strong><span>本次截止日期</span></div></div>{cleanup?.candidates?.length > 0 ? <div className="storage-cleanup-list">{cleanup.candidates.slice(0, 8).map((item) => <div key={item.relativePath}><span className="mono">{item.relativePath}</span><strong>{(item.bytes / 1024 ** 2).toFixed(2)} MB</strong><small>{item.reason} · {item.updatedAt?.replace("T", " ").slice(0, 19)}</small></div>)}{cleanup.candidates.length > 8 && <small className="muted-copy">还有 {cleanup.candidates.length - 8} 个候选文件,执行时会按服务端预览结果处理。</small>}</div> : <div className="empty-table">没有满足保留周期且未被生产证据引用的临时文件。先调整“存储 · storage.retention_days”后重新生成预览。</div>}</section>}
{(section === "overview" || section === "deployment" || section === "storage") && <section className="studio-card wide-card"><SectionBar title="数据库快照备份" detail={`${readiness.backups?.count || 0} 个本地快照`} action={<button className="subtle" onClick={createBackup} disabled={backupBusy}><Database size={14} />{backupBusy ? "创建中…" : "立即创建快照"}</button>} /><div className="backup-summary-row"><div><Database size={18} /><strong>当前业务真源:SQLite</strong><span>快照写入 `data/backups/`,创建动作进入审计日志。</span></div><StatusBadge status={readiness.backups?.count ? "ready" : "needs-evidence"} label={readiness.backups?.count ? "已有备份" : "待创建"} /></div><div className="backup-list">{(readiness.backups?.backups || []).slice(0, 6).map((backup) => <div className="backup-row" key={backup.relativePath}><div><strong>{backup.relativePath}</strong><span>{formatAdminDate(backup.modifiedAt)} · {(Number(backup.bytes || 0) / 1024 ** 2).toFixed(2)} MB</span></div><StatusBadge status="active" label="可用" /></div>)}{!(readiness.backups?.backups || []).length && <div className="empty-table">还没有数据库快照。</div>}</div></section>}
{(section === "overview" || section === "notifications") && <div className="enterprise-grid enterprise-grid-main"><section className="studio-card"><SectionBar title="通知渠道" detail="事件通知与 Webhook" action={<button className="subtle" onClick={sendNotificationTest}><Bell size={14} />发送测试</button>} /><div className="notification-list">{config.notifications.map((channel) => <div key={channel.id}><div className="notification-icon"><Bell size={16} /></div><div><strong>{channel.name}</strong><span>{channel.kind} · {channel.endpoint || "本地日志"}</span></div><button type="button" className={`switch-control small ${channel.enabled ? "on" : ""}`} onClick={() => toggleNotification(channel)}><span />{channel.enabled ? "启用" : "关闭"}</button></div>)}</div><div className="notification-delivery-list"><div className="subsection-heading"><strong>最近投递</strong><span>{deliveries.length} 条</span></div>{deliveries.slice(0, 5).map((delivery) => <div key={delivery.id}><span>{delivery.event_key}</span><StatusBadge status={delivery.status} label={delivery.status === "delivered" ? "已送达" : delivery.status === "blocked" ? "已阻断" : "失败"} /><small>{delivery.channel_name} · {delivery.created_at?.replace("T", " ").slice(0, 19)}</small></div>)}{!deliveries.length && <small className="muted-copy">还没有通知投递记录。</small>}</div></section><section className="studio-card"><SectionBar title="API 客户端" detail="Runner 与企业集成" /><div className="api-client-list">{config.apiClients.map((client) => <div key={client.id}><FileKey2 size={16} /><div><strong>{client.name}</strong><span>{client.client_key_preview || client.client_key}</span><div className="api-client-scope-list">{(config.apiClientScopes?.length ? config.apiClientScopes : fallbackApiClientScopes).map((scope) => <button key={scope.key} type="button" className={`scope-toggle ${client.scopes.includes(scope.key) ? "active" : ""}`} aria-pressed={client.scopes.includes(scope.key)} title={scope.description} onClick={() => toggleApiClientScope(client, scope.key)}>{scope.label}</button>)}</div></div><StatusBadge status={client.status} label={client.status === "active" ? "有效" : client.status === "revoked" ? "已撤销" : client.status} /><button className="icon-text-button" onClick={() => rotateClientKey(client)}><RefreshCw size={13} />轮换密钥</button><button className="icon-text-button" onClick={() => toggleApiClient(client)}><XCircle size={13} />{client.status === "active" ? "撤销" : "恢复"}</button></div>)}</div><form className="client-create-form" onSubmit={createClient}><input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="新 API 客户端名称" /><button className="subtle" type="submit"><Plus size={15} />创建</button></form>{apiKeyNotice && <p className="form-notice"><CheckCircle2 size={14} />{apiKeyNotice}</p>}</section></div>}
</>}
File diff suppressed because one or more lines are too long