Files
ai-drama-platform/src/components/EnterprisePages.jsx
T

2539 lines
218 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from "react";
import {
Activity,
AlertTriangle,
ArrowUpRight,
Ban,
Bell,
CheckCircle2,
CircleDollarSign,
Clock3,
Database,
Download,
FileKey2,
FileText,
Film,
Gauge,
HardDrive,
KeyRound,
ListChecks,
LogIn,
ListFilter,
Network,
Play,
Plus,
RefreshCw,
Save,
Search,
ServerCog,
Settings2,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
MessageSquare,
Link2,
Paperclip,
Send,
AtSign,
ChevronLeft,
ChevronRight,
X,
UserRoundCheck,
Users,
UserRoundCog,
Webhook,
Workflow,
XCircle,
Zap
} from "lucide-react";
import {
createApiClient,
createSystemUser,
acceptInvitation,
cancelMfaSetup,
changePassword,
fetchAuthSessions,
fetchAuthDevices,
fetchSecurityEvents,
fetchAdminQueue,
fetchWorkerStatus,
dispatchWorker,
disableMfa,
enableMfa,
fetchJob,
fetchInvitations,
fetchMfaStatus,
fetchSystemConfig,
fetchSystemHealth,
fetchSystemReadiness,
createSystemBackup,
probeModel,
registerModel,
retryJob,
revokeAuthSession,
revokeOtherAuthSessions,
trustAuthDevice,
untrustAuthDevice,
cancelJob,
runJob,
updateModel,
updateApiClient,
rotateApiClientKey,
updateJobPriority,
operateRunner,
saveSystemConfig,
startMfaSetup,
updateFeatureFlag,
updateNotificationChannel,
previewInvitation,
registerInvitedUser,
testNotification,
fetchNotificationDeliveries,
fetchStorageUsage,
fetchSsoProviders,
fetchIdentityCenter,
fetchOrganizationCommercial,
fetchOrganizationUsage,
fetchOrganizationInvoices,
exportOrganizationCommercial,
exportOrganizationUsage,
exportOrganizationUsageCsv,
exportOrganizationInvoices,
exportOrganizationInvoicesCsv,
fetchSystemUser,
fetchSystemUsers,
resetSystemUserMfa,
resetSystemUserPassword,
revokeSystemUserSessions,
updateOrganizationBilling,
generateOrganizationInvoice,
updateOrganizationInvoiceStatus,
updateCostCenter,
updateQuotaAllocation,
updateSystemUser,
updateSystemUserMemberships,
batchQueueAction,
updateIdentityPolicy,
createIdentityProvider,
updateIdentityProvider,
probeIdentityProvider,
createDirectorySync,
updateDirectorySync,
rotateDirectorySyncToken,
ssoStartUrl,
fetchWorkItems,
fetchTasks,
createTask,
updateTask,
fetchTaskDetail,
createTaskComment,
addTaskLink,
removeTaskLink,
fetchProjectActivity,
fetchProductionGraph,
fetchAuditEvents,
fetchAuditEvent,
exportAuditEvents,
fetchNotifications,
fetchNotificationPreferences,
markNotificationRead,
markAllNotificationsRead,
updateNotificationPreference
} from "../lib/api";
import { downloadJson } from "../lib/exporters";
function toneForStatus(status) {
if (["ready", "active", "completed", "pass", "enforced"].includes(status)) return "ok";
if (["failed", "blocked", "error", "disabled"].includes(status)) return "warn";
return "neutral";
}
function AdminHeader({ eyebrow, title, description, action }) {
return (
<div className="enterprise-header">
<div>
<span className="card-kicker">{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
{action}
</div>
);
}
function AdminKpi({ label, value, detail, icon: Icon, tone = "neutral" }) {
return (
<div className={`admin-kpi ${tone}`}>
<div className="admin-kpi-icon"><Icon size={18} /></div>
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
</div>
);
}
function SectionBar({ title, detail, action }) {
return <div className="section-bar"><div><h3>{title}</h3>{detail && <span>{detail}</span>}</div>{action}</div>;
}
function ContextLink({ children, onClick }) {
return <button className="context-link" onClick={onClick}>{children}<ArrowUpRight size={14} /></button>;
}
const workItemKindLabels = {
task: "协作任务",
review: "审片",
job: "生成",
asset: "资产",
delivery: "交付",
invitation: "邀请"
};
function formatWorkItemTime(value) {
const date = new Date(value || "");
if (Number.isNaN(date.getTime())) return "刚刚";
const elapsed = Math.max(0, Date.now() - date.getTime());
if (elapsed < 60 * 1000) return "刚刚";
if (elapsed < 60 * 60 * 1000) return `${Math.floor(elapsed / 60000)} 分钟前`;
if (elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`;
return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
}
function workItemTone(item) {
if (item.priority === "high" || ["failed", "blocked", "changes_requested", "rejected", "needs-evidence"].includes(item.status)) return "warn";
if (item.status === "approved" || item.status === "completed") return "ok";
return "neutral";
}
const notificationCategoryLabels = {
job: "生成任务",
review: "审片流程",
delivery: "交付运营",
task: "协作任务",
access: "组织访问",
billing: "用量与配额",
system: "系统通知"
};
function formatNotificationTime(value) {
const date = new Date(value || "");
if (Number.isNaN(date.getTime())) return "刚刚";
const elapsed = Math.max(0, Date.now() - date.getTime());
if (elapsed < 60 * 1000) return "刚刚";
if (elapsed < 60 * 60 * 1000) return `${Math.floor(elapsed / 60000)} 分钟前`;
if (elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`;
return date.toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
const taskStatusLabels = {
open: "待处理",
in_progress: "进行中",
blocked: "已阻塞",
done: "已完成",
cancelled: "已取消"
};
const taskPriorityLabels = {
high: "高优先级",
medium: "普通",
low: "低优先级"
};
function formatTaskDue(value, overdue = false) {
if (!value) return "无截止时间";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "截止时间待确认";
return `${overdue ? "已逾期 · " : "截止 "}${date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" })}`;
}
function notificationIcon(severity) {
if (severity === "error") return AlertTriangle;
if (severity === "success") return CheckCircle2;
if (severity === "warning") return ShieldAlert;
return Bell;
}
function notificationTone(severity) {
if (severity === "error" || severity === "warning") return "warn";
if (severity === "success") return "ok";
return "neutral";
}
export function NotificationBell({ contextOverrides = {}, onOpen }) {
const [data, setData] = useState({ notifications: [], unreadCount: 0 });
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId].join("|");
async function load() {
setLoading(true);
try {
setData(await fetchNotifications({ limit: 8 }, contextOverrides));
} catch {
setData((current) => current);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
const timer = window.setInterval(load, 30000);
return () => window.clearInterval(timer);
}, [scopeKey]);
async function readAndOpen(notification) {
try {
const result = await markNotificationRead(notification.id, true, contextOverrides);
setData(result);
} catch {
// The destination page still remains usable if the notification was already read elsewhere.
}
setOpen(false);
if (notification.targetTab) onOpen?.(notification.targetTab);
}
async function readAll(event) {
event.stopPropagation();
try {
setData(await markAllNotificationsRead(contextOverrides));
} catch {
// Keep the popover open so the user can retry after the API recovers.
}
}
return (
<div className="notification-bell-wrap">
<button className={`icon-only-button notification-bell-button ${open ? "active" : ""}`} title="通知中心" aria-label="通知中心" aria-expanded={open} onClick={() => { setOpen((current) => !current); if (!open) load(); }}>
<Bell size={16} />
{data.unreadCount > 0 && <span className="notification-unread-badge">{data.unreadCount > 99 ? "99+" : data.unreadCount}</span>}
</button>
{open && <>
<button className="notification-popover-scrim" aria-label="关闭通知" onClick={() => setOpen(false)} />
<div className="notification-popover">
<div className="notification-popover-head"><div><strong>通知中心</strong><span>{data.unreadCount ? `${data.unreadCount} 条未读` : "全部已读"}</span></div><div className="row-actions"><button className="icon-only-button" title="刷新通知" aria-label="刷新通知" onClick={load} disabled={loading}><RefreshCw size={14} /></button><button className="icon-text-button" onClick={readAll} disabled={!data.unreadCount}><CheckCircle2 size={13} />全部已读</button></div></div>
<div className="notification-popover-list">
{loading && <div className="empty-state">读取通知…</div>}
{!loading && data.notifications.slice(0, 6).map((notification) => { const Icon = notificationIcon(notification.severity); return <button className={`notification-popover-row ${notification.read ? "read" : "unread"}`} key={notification.id} onClick={() => readAndOpen(notification)}><span className={`notification-dot ${notificationTone(notification.severity)}`}><Icon size={13} /></span><span className="notification-popover-copy"><strong>{notification.title}</strong><span>{notification.body}</span><small>{notificationCategoryLabels[notification.category] || notification.category} · {formatNotificationTime(notification.createdAt)}</small></span><ArrowUpRight size={14} /></button>; })}
{!loading && !data.notifications.length && <div className="empty-state">当前工作区没有通知。</div>}
</div>
<button className="notification-popover-footer" onClick={() => { setOpen(false); onOpen?.("notifications"); }}>打开完整通知中心<ArrowUpRight size={14} /></button>
</div>
</>}
</div>
);
}
export function NotificationCenterPage({ contextOverrides = {}, setActiveTab }) {
const [data, setData] = useState({ notifications: [], unreadCount: 0 });
const [preferences, setPreferences] = useState([]);
const [filter, setFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [preferenceBusy, setPreferenceBusy] = useState("");
const [notice, setNotice] = useState("");
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId].join("|");
async function load() {
setLoading(true);
try {
const [nextData, nextPreferences] = await Promise.all([
fetchNotifications({ limit: 200, unreadOnly: filter === "unread" }, contextOverrides),
fetchNotificationPreferences(contextOverrides)
]);
setData(nextData);
setPreferences(nextPreferences.preferences || []);
setNotice("");
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [scopeKey, filter]);
async function markRead(notification, read = true) {
try {
setData(await markNotificationRead(notification.id, read, contextOverrides));
} catch (error) {
setNotice(error.message);
}
}
async function markAllRead() {
try {
setData(await markAllNotificationsRead(contextOverrides));
setNotice("当前工作区通知已全部标记为已读");
} catch (error) {
setNotice(error.message);
}
}
async function togglePreference(preference) {
setPreferenceBusy(preference.category);
try {
const result = await updateNotificationPreference(preference.category, !preference.enabled, contextOverrides);
setPreferences(result.preferences || []);
setNotice(`${preference.label}通知已${!preference.enabled ? "开启" : "关闭"}`);
} catch (error) {
setNotice(error.message);
} finally {
setPreferenceBusy("");
}
}
return (
<div className="enterprise-page notification-center-page">
<AdminHeader eyebrow="ACCOUNT / NOTIFICATIONS" title="通知中心" description="任务、审片、交付、组织访问和系统策略会在这里留下可追溯的站内消息;消息范围跟随当前组织与工作区。" action={<div className="header-actions"><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新</button><button className="primary" onClick={markAllRead} disabled={!data.unreadCount}><CheckCircle2 size={15} />全部已读</button></div>} />
{notice && <div className="inline-notice ok"><Bell size={15} />{notice}</div>}
<div className="admin-kpi-grid"><AdminKpi icon={Bell} label="未读通知" value={data.unreadCount} detail="当前工作区" tone={data.unreadCount ? "warn" : "ok"} /><AdminKpi icon={Activity} label="当前列表" value={data.notifications.length} detail={filter === "unread" ? "仅未读" : "最近消息"} tone="neutral" /><AdminKpi icon={ShieldCheck} label="权限范围" value="RBAC" detail="服务端已过滤" tone="ok" /><AdminKpi icon={Database} label="消息存储" value="SQLite" detail="本地可审计" tone="neutral" /></div>
<section className="studio-card wide-card"><SectionBar title="消息收件箱" detail={`${data.scope?.organizationId || "当前组织"} · ${data.scope?.workspaceId || "当前工作区"}`} action={<div className="segmented-control"><button className={filter === "all" ? "selected" : ""} onClick={() => setFilter("all")}>全部</button><button className={filter === "unread" ? "selected" : ""} onClick={() => setFilter("unread")}>未读 {data.unreadCount ? `(${data.unreadCount})` : ""}</button></div>} />
{loading ? <div className="loading-state"><RefreshCw size={18} />读取通知…</div> : <div className="notification-center-list">{data.notifications.map((notification) => { const Icon = notificationIcon(notification.severity); return <article className={`notification-center-row ${notification.read ? "read" : "unread"}`} key={notification.id}><span className={`notification-center-icon ${notificationTone(notification.severity)}`}><Icon size={17} /></span><div className="notification-center-copy"><div><span className="notification-category">{notificationCategoryLabels[notification.category] || notification.category}</span><small>{formatNotificationTime(notification.createdAt)}</small></div><strong>{notification.title}</strong><p>{notification.body}</p><div className="notification-meta"><code>{notification.eventKey}</code>{notification.targetId && <span>关联 {notification.targetId}</span>}</div></div><div className="notification-center-actions">{notification.targetTab && <button className="icon-text-button" onClick={() => { markRead(notification); setActiveTab?.(notification.targetTab); }}><ArrowUpRight size={14} />打开</button>}<button className="icon-text-button" onClick={() => markRead(notification, !notification.read)}>{notification.read ? "标记未读" : "标记已读"}</button></div></article>; })}{!data.notifications.length && <div className="empty-state">当前筛选下没有消息。</div>}</div>}
</section>
<section className="studio-card wide-card"><SectionBar title="我的通知偏好" detail="只影响当前组织;切换组织后使用独立配置" /><div className="notification-preference-list">{preferences.map((preference) => <div className="notification-preference-row" key={preference.category}><div className={`notification-preference-icon ${preference.enabled ? "ok" : "muted"}`}><Bell size={16} /></div><div><strong>{preference.label}</strong><span>{preference.description}</span></div><button type="button" className={`switch-control ${preference.enabled ? "on" : ""}`} onClick={() => togglePreference(preference)} disabled={preferenceBusy === preference.category}><span />{preferenceBusy === preference.category ? "保存中…" : preference.enabled ? "已开启" : "已关闭"}</button></div>)}{!preferences.length && <div className="empty-state">暂无可配置的通知类别。</div>}</div></section>
</div>
);
}
export function TaskCenterPage({ platformContext, contextOverrides = {} }) {
const current = platformContext?.context || {};
const permissions = current.permissions || [];
const canManage = permissions.includes("task:manage");
const canComplete = permissions.includes("task:complete");
const members = [...(platformContext?.platform?.projectMembers || []), ...(platformContext?.platform?.workspaceMembers || [])]
.filter((member) => member.status === "active")
.filter((member, index, list) => list.findIndex((item) => item.user_id === member.user_id) === index)
.map((member) => ({ id: member.user_id, displayName: member.display_name, email: member.email || "" }));
const currentUser = current.currentUser || {};
const assigneeOptions = [{ id: currentUser.id, displayName: `${currentUser.displayName || currentUser.display_name || currentUser.email || "我"}(我)`, email: currentUser.email || "" }, ...members.filter((member) => member.id !== currentUser.id)]
.filter((member, index, list) => member.id && list.findIndex((item) => item.id === member.id) === index);
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
const [statusFilter, setStatusFilter] = useState("all");
const [mineOnly, setMineOnly] = useState(false);
const [data, setData] = useState({ tasks: [], summary: { total: 0, open: 0, blocked: 0, done: 0, overdue: 0 } });
const [activity, setActivity] = useState([]);
const [catalog, setCatalog] = useState({ shots: [], assets: [] });
const [loading, setLoading] = useState(true);
const [activityLoading, setActivityLoading] = useState(true);
const [busyId, setBusyId] = useState("");
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
const [selectedTaskId, setSelectedTaskId] = useState("");
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailTab, setDetailTab] = useState("discussion");
const [comment, setComment] = useState("");
const [replyTo, setReplyTo] = useState("");
const [mentionIds, setMentionIds] = useState([]);
const [linkType, setLinkType] = useState("shot");
const [linkTargetId, setLinkTargetId] = useState("");
const [filePath, setFilePath] = useState("");
const [form, setForm] = useState({ title: "", description: "", kind: "production", priority: "medium", assigneeUserId: currentUser.id || "", targetTab: "creator-home", targetId: "", dueAt: "", linkShotId: "" });
useEffect(() => {
if (currentUser.id && !form.assigneeUserId) setForm((currentForm) => ({ ...currentForm, assigneeUserId: currentUser.id }));
}, [currentUser.id]);
async function load() {
setLoading(true);
setActivityLoading(true);
const [taskResult, activityResult, graphResult] = await Promise.allSettled([
fetchTasks({ status: statusFilter, assignedTo: mineOnly ? "me" : "", limit: 200 }, contextOverrides),
fetchProjectActivity({ limit: 80 }, contextOverrides),
fetchProductionGraph(contextOverrides)
]);
if (taskResult.status === "fulfilled") { setData(taskResult.value); setError(""); } else setError(taskResult.reason.message);
if (activityResult.status === "fulfilled") setActivity(activityResult.value.activities || []);
if (graphResult.status === "fulfilled") setCatalog({ shots: graphResult.value.graph?.shots || [], assets: graphResult.value.graph?.assets || [] });
setLoading(false);
setActivityLoading(false);
}
useEffect(() => {
let active = true;
Promise.allSettled([
fetchTasks({ status: statusFilter, assignedTo: mineOnly ? "me" : "", limit: 200 }, contextOverrides),
fetchProjectActivity({ limit: 80 }, contextOverrides),
fetchProductionGraph(contextOverrides)
]).then(([taskResult, activityResult, graphResult]) => {
if (!active) return;
if (taskResult.status === "fulfilled") { setData(taskResult.value); setError(""); } else setError(taskResult.reason.message);
if (activityResult.status === "fulfilled") setActivity(activityResult.value.activities || []);
if (graphResult.status === "fulfilled") setCatalog({ shots: graphResult.value.graph?.shots || [], assets: graphResult.value.graph?.assets || [] });
}).finally(() => { if (active) { setLoading(false); setActivityLoading(false); } });
return () => { active = false; };
}, [scopeKey, statusFilter, mineOnly]);
async function openTask(taskId) {
setSelectedTaskId(taskId);
setDetailLoading(true);
setDetailTab("discussion");
try { setDetail(await fetchTaskDetail(taskId, contextOverrides)); } catch (loadError) { setError(loadError.message); } finally { setDetailLoading(false); }
}
async function submitTask(event) {
event.preventDefault();
setBusyId("create");
setNotice("");
try {
const result = await createTask({ ...form, dueAt: form.dueAt ? new Date(`${form.dueAt}T23:59:59`).toISOString() : null }, contextOverrides);
if (form.linkShotId) await addTaskLink(result.task.id, { linkType: "shot", targetId: form.linkShotId }, contextOverrides);
setForm({ title: "", description: "", kind: "production", priority: "medium", assigneeUserId: currentUser.id || "", targetTab: "creator-home", targetId: "", dueAt: "", linkShotId: "" });
setNotice("协作任务已创建,负责人、关联镜头和审计记录已建立");
await load();
await openTask(result.task.id);
} catch (createError) { setError(createError.message); } finally { setBusyId(""); }
}
async function changeTaskStatus(task, status) {
setBusyId(task.id);
setNotice("");
try {
const result = await updateTask(task.id, { status }, contextOverrides);
setData((currentData) => ({ ...currentData, tasks: currentData.tasks.map((item) => item.id === task.id ? result.task : item) }));
if (selectedTaskId === task.id) setDetail((currentDetail) => currentDetail ? { ...currentDetail, task: result.task } : currentDetail);
setNotice(status === "done" ? "任务已完成" : "任务状态已更新,并已进入项目活动流");
await load();
} catch (updateError) { setError(updateError.message); } finally { setBusyId(""); }
}
async function submitComment(event) {
event.preventDefault();
if (!selectedTaskId || !comment.trim()) return;
setBusyId("comment");
try {
const result = await createTaskComment(selectedTaskId, { body: comment.trim(), parentCommentId: replyTo || null, mentionUserIds: mentionIds }, contextOverrides);
setDetail(result);
setComment("");
setReplyTo("");
setMentionIds([]);
setNotice("讨论已记录,相关负责人和 @成员已收到站内通知");
const activityResult = await fetchProjectActivity({ limit: 80 }, contextOverrides);
setActivity(activityResult.activities || []);
setData((currentData) => ({ ...currentData, tasks: currentData.tasks.map((item) => item.id === selectedTaskId ? result.task : item) }));
} catch (commentError) { setError(commentError.message); } finally { setBusyId(""); }
}
async function submitLink(event) {
event.preventDefault();
if (!selectedTaskId) return;
const targetId = linkType === "file" ? filePath.trim() : linkTargetId;
if (!targetId) return;
setBusyId("link");
try {
const result = await addTaskLink(selectedTaskId, { linkType, targetId }, contextOverrides);
setDetail(result);
setLinkTargetId("");
setFilePath("");
setNotice("生产对象已关联到任务");
const activityResult = await fetchProjectActivity({ limit: 80 }, contextOverrides);
setActivity(activityResult.activities || []);
} catch (linkError) { setError(linkError.message); } finally { setBusyId(""); }
}
async function unlink(linkId) {
setBusyId(linkId);
try { setDetail(await removeTaskLink(selectedTaskId, linkId, contextOverrides)); setNotice("任务关联已移除"); } catch (linkError) { setError(linkError.message); } finally { setBusyId(""); }
}
const selectedTask = detail?.task || data.tasks.find((task) => task.id === selectedTaskId);
const linkOptions = linkType === "shot" ? catalog.shots : catalog.assets;
return (
<div className="enterprise-page task-center-page">
<AdminHeader eyebrow="CREATOR PORTAL / COLLABORATION" title="协作任务中心" description="任务、讨论、@成员、镜头关联和项目活动流统一在一个可审计的协作对象里,适合剧本、资产、生成、审片和交付团队协同。" action={<button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新工作区</button>} />
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
{error && <div className="inline-notice warn"><AlertTriangle size={15} /><span>{error}</span><button className="subtle" onClick={load}>重试</button></div>}
<div className="admin-kpi-grid">
<AdminKpi icon={ListChecks} label="任务总数" value={data.summary?.total ?? 0} detail="当前项目范围" tone="neutral" />
<AdminKpi icon={Clock3} label="进行中" value={data.summary?.open ?? 0} detail="待处理 + 进行中" tone="ok" />
<AdminKpi icon={AlertTriangle} label="阻塞 / 逾期" value={(data.summary?.blocked ?? 0) + (data.summary?.overdue ?? 0)} detail={`${data.summary?.blocked ?? 0} 项阻塞 · ${data.summary?.overdue ?? 0} 项逾期`} tone={(data.summary?.blocked || data.summary?.overdue) ? "warn" : "ok"} />
<AdminKpi icon={Activity} label="项目活动" value={activity.length} detail="任务、镜头、生成、审片、交付" tone="neutral" />
</div>
<div className="enterprise-grid enterprise-grid-main task-center-grid">
<section className="studio-card wide-card">
<SectionBar title="项目任务清单" detail={loading ? "读取中…" : `${data.tasks?.length || 0} 项符合当前筛选`} action={<div className="segmented-control"><button className={statusFilter === "all" ? "selected" : ""} onClick={() => setStatusFilter("all")}>全部</button><button className={statusFilter === "open" ? "selected" : ""} onClick={() => setStatusFilter("open")}>待处理</button><button className={statusFilter === "in_progress" ? "selected" : ""} onClick={() => setStatusFilter("in_progress")}>进行中</button><button className={statusFilter === "blocked" ? "selected" : ""} onClick={() => setStatusFilter("blocked")}>阻塞</button><button className={statusFilter === "done" ? "selected" : ""} onClick={() => setStatusFilter("done")}>已完成</button><button className={`task-mine-toggle ${mineOnly ? "selected" : ""}`} onClick={() => setMineOnly((value) => !value)}>只看我的</button></div>} />
{loading ? <div className="loading-state"><RefreshCw size={18} />读取协作任务…</div> : <div className="task-center-list">{(data.tasks || []).map((task) => <article className={`task-center-row ${task.status} ${task.overdue ? "overdue" : ""} ${selectedTaskId === task.id ? "selected" : ""}`} key={task.id} onClick={() => openTask(task.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openTask(task.id); } }} role="button" tabIndex="0">
<div className="task-center-main"><div className="task-center-meta"><span className={`task-priority ${task.priority}`}>{taskPriorityLabels[task.priority] || task.priority}</span><span>{task.kind}</span><small>{formatTaskDue(task.dueAt, task.overdue)}</small></div><strong>{task.title}</strong><p>{task.description || "暂无任务说明"}</p><div className="task-center-submeta"><span>负责人:{task.assignee?.displayName || "未分派"}</span><span>创建人:{task.createdBy?.displayName || "未知"}</span><span><MessageSquare size={11} /> {task.commentCount || 0}</span><span><Link2 size={11} /> {task.linkCount || 0}</span><span>更新于 {formatNotificationTime(task.updatedAt)}</span></div></div>
<div className="task-center-actions">{canManage ? <select aria-label={`${task.title} 状态`} value={task.status} disabled={busyId === task.id} onClick={(event) => event.stopPropagation()} onChange={(event) => { event.stopPropagation(); changeTaskStatus(task, event.target.value); }}>{Object.entries(taskStatusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select> : task.assignee?.id === currentUser.id && canComplete && !["done", "cancelled"].includes(task.status) ? <button className="subtle" onClick={(event) => { event.stopPropagation(); changeTaskStatus(task, "done"); }} disabled={busyId === task.id}><CheckCircle2 size={14} />完成</button> : <StatusBadge status={task.status} label={taskStatusLabels[task.status] || task.status} />}<ChevronRight size={15} /></div>
</article>)}{!data.tasks?.length && <div className="empty-state">当前筛选下没有协作任务。</div>}</div>}
</section>
<section className="studio-card task-create-panel">
<SectionBar title="新建协作任务" detail={canManage ? "当前组织 / 工作区 / 项目范围" : "需要 task:manage 权限"} />
{canManage ? <form className="enterprise-form-grid task-create-form" onSubmit={submitTask}>
<label className="span-two">任务标题<input required maxLength="180" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="例如:补齐 E01-S03 的角色连续性证据" /></label>
<label className="span-two">任务说明<textarea rows="4" maxLength="4000" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="写清楚验收标准、关联镜头或需要交付的文件。" /></label>
<label>任务类型<select value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}><option value="production">生产执行</option><option value="script">剧本与对白</option><option value="asset">资产与连续性</option><option value="generation">生成与队列</option><option value="review">审片与 QA</option><option value="delivery">交付与发布</option></select></label>
<label>优先级<select value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })}><option value="high">高优先级</option><option value="medium">普通</option><option value="low">低优先级</option></select></label>
<label>负责人<select value={form.assigneeUserId} onChange={(event) => setForm({ ...form, assigneeUserId: event.target.value })}><option value="">暂不分派</option>{assigneeOptions.map((member) => <option key={member.id} value={member.id}>{member.displayName}{member.email ? ` · ${member.email}` : ""}</option>)}</select></label>
<label>截止日期<input type="date" value={form.dueAt} onChange={(event) => setForm({ ...form, dueAt: event.target.value })} /></label>
<label>关联镜头<select value={form.linkShotId} onChange={(event) => setForm({ ...form, linkShotId: event.target.value })}><option value="">暂不关联</option>{catalog.shots.map((shot) => <option key={shot.id} value={shot.id}>S{String(shot.shotNumber || shot.shot_number || 0).padStart(2, "0")} · {shot.title}</option>)}</select></label>
<label>打开页面<select value={form.targetTab} onChange={(event) => setForm({ ...form, targetTab: event.target.value })}><option value="creator-home">我的工作台</option><option value="script">剧本拆解</option><option value="casting">资产与选角</option><option value="director">导演工作台</option><option value="jobs">生成队列</option><option value="qa">审片中心</option><option value="export">交付运营</option></select></label>
<div className="form-actions span-two"><button className="primary" type="submit" disabled={busyId === "create"}><Plus size={15} />{busyId === "create" ? "创建中…" : "创建任务"}</button></div>
</form> : <div className="empty-state">当前角色可以查看项目任务{canComplete ? ",并更新自己负责的任务状态" : ",但不能创建或分派任务"}。</div>}
</section>
<section className="studio-card wide-card task-activity-panel">
<SectionBar title="项目活动流" detail="服务端按当前组织、工作区和项目过滤" action={<button className="icon-text-button" onClick={load} disabled={activityLoading}><RefreshCw size={13} />刷新</button>} />
{activityLoading ? <div className="loading-state"><RefreshCw size={18} />读取项目活动…</div> : <div className="task-activity-list">{activity.slice(0, 24).map((item) => <div className="task-activity-row" key={item.id}><span className="task-activity-icon"><Activity size={14} /></span><div><strong>{item.actor?.displayName || "系统"} {item.label}</strong><p>{item.targetLabel || item.targetId}</p><small>{formatNotificationTime(item.createdAt)} · {item.targetType}</small></div><span className={`activity-result ${item.result}`}>{item.result}</span></div>)}{!activity.length && <div className="empty-state">当前项目还没有可展示的活动记录。</div>}</div>}
</section>
</div>
{selectedTaskId && <div className="task-detail-backdrop" role="presentation" onClick={() => setSelectedTaskId("")}><aside className="task-detail-drawer" role="dialog" aria-modal="true" aria-label="任务详情" onClick={(event) => event.stopPropagation()}>
<div className="task-detail-head"><div><span className="card-kicker">TASK / DETAIL</span><h3>{detailLoading ? "读取任务详情…" : selectedTask?.title || "任务详情"}</h3><span>{selectedTask ? `${taskPriorityLabels[selectedTask.priority] || selectedTask.priority} · ${taskStatusLabels[selectedTask.status] || selectedTask.status}` : ""}</span></div><button className="icon-only-button" title="关闭任务详情" aria-label="关闭任务详情" onClick={() => setSelectedTaskId("")}><X size={17} /></button></div>
{detailLoading ? <div className="loading-state"><RefreshCw size={18} />读取任务协作数据…</div> : detail ? <>
<div className="task-detail-summary"><div><span>负责人</span><strong>{detail.task.assignee?.displayName || "未分派"}</strong></div><div><span>截止</span><strong>{formatTaskDue(detail.task.dueAt, detail.task.overdue)}</strong></div><div><span>创建人</span><strong>{detail.task.createdBy?.displayName || "未知"}</strong></div></div>
<div className="task-detail-tabs"><button className={detailTab === "discussion" ? "selected" : ""} onClick={() => setDetailTab("discussion")}><MessageSquare size={14} />讨论 {detail.comments?.length || 0}</button><button className={detailTab === "links" ? "selected" : ""} onClick={() => setDetailTab("links")}><Link2 size={14} />关联 {detail.links?.length || 0}</button></div>
{detailTab === "discussion" && <div className="task-detail-body"><div className="task-description-block"><span>任务说明</span><p>{detail.task.description || "暂无任务说明"}</p></div><div className="task-comment-list">{(detail.comments || []).map((item) => <div className={`task-comment ${item.parentCommentId ? "reply" : ""}`} key={item.id}><div className="task-comment-avatar">{(item.author?.displayName || "成").slice(0, 1)}</div><div><div className="task-comment-meta"><strong>{item.author?.displayName || item.author?.id}</strong><small>{formatNotificationTime(item.createdAt)}</small></div><p>{item.body}</p>{item.mentions?.length ? <small className="task-comment-mentions">@ {item.mentions.map((mention) => mention.displayName).join("、")}</small> : null}<button className="icon-text-button" onClick={() => { setReplyTo(item.id); setComment(`回复 @${item.author?.displayName || "成员"}:`); }}>回复</button></div></div>)}{!detail.comments?.length && <div className="empty-state">还没有讨论。把验收标准、修改意见或证据要求写在这里。</div>}</div><form className="task-comment-form" onSubmit={submitComment}><textarea value={comment} onChange={(event) => setComment(event.target.value)} placeholder="写评论,使用 @成员 或右侧选择成员通知协作者…" rows="4" /><div className="task-comment-tools"><label><AtSign size={13} /><select value="" onChange={(event) => { const id = event.target.value; const member = members.find((item) => item.id === id); if (!member) return; setMentionIds((ids) => ids.includes(id) ? ids : [...ids, id]); setComment((value) => `${value}${value && !value.endsWith(" ") ? " " : ""}@${member.displayName} `); }}><option value="">提及成员</option>{members.map((member) => <option key={member.id} value={member.id}>{member.displayName}</option>)}</select></label>{replyTo && <button type="button" className="icon-text-button" onClick={() => { setReplyTo(""); setComment(""); }}>取消回复</button>}<button type="submit" className="primary" disabled={busyId === "comment" || !comment.trim()}><Send size={14} />发送评论</button></div></form></div>}
{detailTab === "links" && <div className="task-detail-body"><div className="task-link-list">{(detail.links || []).map((link) => <div className="task-link-row" key={link.id}><span className="task-link-icon">{link.type === "file" ? <Paperclip size={14} /> : <Link2 size={14} />}</span><div><strong>{link.label}</strong><small>{link.type} · {link.targetId}</small></div>{canManage && <button className="icon-only-button" title="移除关联" aria-label="移除关联" onClick={() => unlink(link.id)} disabled={busyId === link.id}><X size={14} /></button>}</div>)}{!detail.links?.length && <div className="empty-state">还没有关联镜头、资产或本地附件。</div>}</div>{canManage && <form className="task-link-form" onSubmit={submitLink}><label>关联类型<select value={linkType} onChange={(event) => { setLinkType(event.target.value); setLinkTargetId(""); }}><option value="shot">镜头</option><option value="asset">资产</option><option value="file">本地附件路径</option></select></label>{linkType === "file" ? <label className="span-two">本地文件路径<input value={filePath} onChange={(event) => setFilePath(event.target.value)} placeholder="/Users/xz/Documents/.../review.mp4" /></label> : <label className="span-two">生产对象<select value={linkTargetId} onChange={(event) => setLinkTargetId(event.target.value)}><option value="">选择对象</option>{linkOptions.map((item) => <option key={item.id} value={item.id}>{item.title || item.name || item.id}</option>)}</select></label>}<div className="form-actions span-two"><button className="subtle" type="submit" disabled={busyId === "link"}><Link2 size={14} />添加关联</button></div></form>}</div>}
</> : <div className="empty-state">任务详情读取失败,请关闭后重试。</div>}
</aside></div>}
</div>
);
}
export function CreatorHomePage({ project, platformContext, contextOverrides = {}, setActiveTab, canCreateProject, allowedTabs = [] }) {
const platform = platformContext?.platform || {};
const current = platformContext?.context;
const projects = platform.projects || [];
const jobs = project.productionJobs || [];
const readyShots = project.shots.filter((shot) => shot.status === "ready").length;
const currentProject = current?.currentProject?.name || project.series.title;
const canOpen = (tab) => allowedTabs.includes(tab);
const [workItems, setWorkItems] = useState({ items: [], summary: { total: 0 } });
const [workItemsLoading, setWorkItemsLoading] = useState(true);
const [workItemsError, setWorkItemsError] = useState("");
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
async function loadWorkItems() {
setWorkItemsLoading(true);
try {
const payload = await fetchWorkItems(contextOverrides);
setWorkItems(payload);
setWorkItemsError("");
} catch (error) {
setWorkItemsError(error.message);
} finally {
setWorkItemsLoading(false);
}
}
useEffect(() => {
let active = true;
setWorkItemsLoading(true);
fetchWorkItems(contextOverrides).then((payload) => {
if (active) {
setWorkItems(payload);
setWorkItemsError("");
}
}).catch((error) => {
if (active) setWorkItemsError(error.message);
}).finally(() => {
if (active) setWorkItemsLoading(false);
});
return () => { active = false; };
}, [scopeKey]);
const visibleWorkItems = (workItems.items || []).filter((item) => canOpen(item.targetTab));
return (
<div className="enterprise-page">
<AdminHeader
eyebrow="CREATOR PORTAL / WORKSPACE"
title="我的创作工作台"
description={`${current?.currentWorkspace?.name || "短剧生产中心"} · ${currentProject} · 当前工作区的项目、任务和审片状态集中在这里。`}
action={canCreateProject && <button className="primary" onClick={() => setActiveTab("factory")}><Plus size={16} />新建项目</button>}
/>
<div className="admin-kpi-grid">
<AdminKpi icon={Workflow} label="我的项目" value={projects.length} detail="当前工作区可访问" tone="ok" />
<AdminKpi icon={Clock3} label="待处理事项" value={workItemsLoading || workItemsError ? "—" : visibleWorkItems.length} detail="当前角色可访问" tone="neutral" />
<AdminKpi icon={ShieldCheck} label="镜头准备度" value={`${project.production?.commercialReadiness || 72}%`} detail={`${readyShots}/${project.shots.length} 个镜头已锁定`} tone="ok" />
<AdminKpi icon={CircleDollarSign} label="本月用量" value={platform.usage ? `${platform.usage.totalCost}` : "—"} detail={platform.usage ? "本地计量成本" : "按权限隐藏"} tone="neutral" />
</div>
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card wide-card">
<SectionBar title="最近项目" detail="按最近更新排序" action={canCreateProject && <ContextLink onClick={() => setActiveTab("factory")}>查看全部项目</ContextLink>} />
<div className="enterprise-table project-table">
<div className="enterprise-table-head"><span>项目</span><span>类型</span><span>阶段</span><span>准备度</span><span>最近更新</span><span>操作</span></div>
{(projects.length ? projects : [{ id: project.series.id, name: project.series.title, type: "AI 漫剧", status: "production", readiness: 72, updated_at: "刚刚" }]).map((item) => (
<div className="enterprise-table-row" key={item.id}>
<strong>{item.name || item.title}</strong>
<span>{item.type}</span>
<StatusBadge status={item.status || item.stage || "production"} label={item.status === "production" || item.stage === "production" ? "制作中" : (item.status || item.stage)} />
<span className="table-emphasis">{item.readiness ?? 72}%</span>
<span>{item.updated_at?.slice(0, 10) || item.updatedAt || "今天"}</span>
<button className="icon-text-button" onClick={() => canOpen("director") && setActiveTab("director")} disabled={!canOpen("director")}>打开 <ArrowUpRight size={14} /></button>
</div>
))}
</div>
</section>
<section className="studio-card">
<SectionBar title="我的待办" detail={workItemsLoading ? "读取中…" : `${visibleWorkItems.length} 项需要当前角色处理`} action={<button className="icon-only-button" title="刷新待办" aria-label="刷新待办" onClick={loadWorkItems} disabled={workItemsLoading}><RefreshCw size={14} /></button>} />
{workItemsError && <div className="inline-notice warn"><AlertTriangle size={15} /><span>{workItemsError}</span><button className="subtle" onClick={loadWorkItems}>重试</button></div>}
<div className="task-list">
{workItemsLoading && <div className="empty-state">正在读取当前组织 / 工作区 / 项目的待办事项…</div>}
{!workItemsLoading && visibleWorkItems.map((item) => (
<button className="task-row" key={item.id} onClick={() => setActiveTab(item.targetTab)}>
<span className={`task-dot ${workItemTone(item)}`} />
<div><strong>{item.title}</strong><span>{workItemKindLabels[item.kind] || item.kind} · {item.detail} · {formatWorkItemTime(item.updatedAt)}</span></div>
<ArrowUpRight size={15} />
</button>
))}
{!workItemsLoading && !visibleWorkItems.length && !workItemsError && <div className="empty-state">当前角色没有待处理事项。</div>}
</div>
</section>
<section className="studio-card">
<SectionBar title="生产快捷入口" detail="从已锁定的流程开始" />
<div className="quick-entry-grid">
{[
["剧本拆解", "长文本 → 分集/场次/镜头", "script", Workflow],
["资产锁定", "角色、场景、道具、声线", "casting", Users],
["导演工作台", "首尾帧与 continuity ledger", "director", SlidersHorizontal],
["生成队列", "本地模型任务与重试", "jobs", Zap]
].filter(([, , tab]) => canOpen(tab)).map(([title, detail, tab, Icon]) => <button key={tab} onClick={() => setActiveTab(tab)}><Icon size={18} /><strong>{title}</strong><span>{detail}</span></button>)}
</div>
</section>
</div>
</div>
);
}
export function LoginPage({ onLogin, loading = false, error = "", initialChallenge = null }) {
const [email, setEmail] = useState("producer@local.test");
const [password, setPassword] = useState("Demo@123456");
const [showPassword, setShowPassword] = useState(false);
const [mfaChallenge, setMfaChallenge] = useState(initialChallenge);
const [mfaSetup, setMfaSetup] = useState(null);
const [mfaCode, setMfaCode] = useState("");
const [ssoProviders, setSsoProviders] = useState([]);
const enrollmentMode = Boolean(mfaChallenge?.mfaEnrollmentRequired);
useEffect(() => {
if (initialChallenge) setMfaChallenge(initialChallenge);
}, [initialChallenge]);
useEffect(() => {
let active = true;
fetchSsoProviders().then((payload) => {
if (active) setSsoProviders(payload.providers || []);
}).catch(() => {
if (active) setSsoProviders([]);
});
return () => { active = false; };
}, []);
async function submit(event) {
event.preventDefault();
if (mfaChallenge && enrollmentMode && !mfaSetup) {
const result = await onLogin({ enrollmentAction: "setup", enrollmentToken: mfaChallenge.enrollmentToken });
if (result?.setup) {
setMfaSetup(result.setup);
setMfaCode("");
}
return;
}
const result = await onLogin(mfaChallenge ? enrollmentMode ? { enrollmentAction: "complete", enrollmentToken: mfaChallenge.enrollmentToken, methodId: mfaSetup.methodId, code: mfaCode } : { challengeToken: mfaChallenge.challengeToken, code: mfaCode } : { email, password });
if (result?.mfaRequired && !mfaChallenge) {
setMfaChallenge(result);
setMfaCode("");
}
}
return (
<main className="auth-page">
<section className="auth-panel">
<div className="auth-brand"><div className="brand-mark"><Film size={22} /></div><div><strong>AI 短剧生产平台</strong><span>Private production control plane</span></div></div>
<div className="auth-copy"><span className="card-kicker">SECURE WORKSPACE ACCESS</span><h1>{!mfaChallenge ? "登录生产平台" : enrollmentMode ? mfaSetup ? "完成 MFA 绑定" : "绑定身份验证器" : "验证身份"}</h1><p>{!mfaChallenge ? "登录后,系统会根据你的组织、工作区、项目角色和系统管理员身份决定可访问的页面与操作。" : enrollmentMode ? mfaSetup ? "请在身份验证器中添加以下密钥,然后输入当前 6 位验证码完成绑定。" : "当前登录策略要求先绑定身份验证器,完成后才会创建正式登录会话。" : "企业身份已验证,请输入身份验证器中的 6 位验证码完成登录。"}</p></div>
<form className="auth-form" onSubmit={submit}>
{mfaChallenge ? enrollmentMode ? mfaSetup ? <><label>一次性密钥<code className="mfa-secret">{mfaSetup.secret}</code></label><label>验证器地址<textarea value={mfaSetup.otpauthUrl} readOnly rows="2" /></label><label>身份验证器验证码<input inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={mfaCode} onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, "").slice(0, 6))} autoFocus required /></label></> : <div className="auth-enrollment-step"><ShieldCheck size={18} /><strong>需要绑定 TOTP 身份验证器</strong><span>点击下方按钮生成一次性密钥。密钥只会在本次绑定流程中显示。</span></div> : <label>身份验证器验证码<input inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={mfaCode} onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, "").slice(0, 6))} autoFocus required /></label> : <><label>邮箱<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="username" required /></label><label>密码<div className="password-field"><input type={showPassword ? "text" : "password"} value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required /><button type="button" onClick={() => setShowPassword((value) => !value)}>{showPassword ? "隐藏" : "显示"}</button></div></label></>}
{error && <div className="auth-error"><AlertTriangle size={15} />{error}</div>}
<button className="primary auth-submit" type="submit" disabled={loading || (Boolean(mfaChallenge) && (!enrollmentMode || Boolean(mfaSetup)) && mfaCode.length !== 6)}><LogIn size={16} />{loading ? "验证中…" : !mfaChallenge ? "登录" : enrollmentMode ? mfaSetup ? "绑定并登录" : "生成绑定密钥" : "验证并登录"}</button>
{mfaChallenge && <button className="subtle auth-back-button" type="button" onClick={() => { setMfaChallenge(null); setMfaSetup(null); setMfaCode(""); }}>返回密码登录</button>}
</form>
{!mfaChallenge && ssoProviders.length > 0 && <div className="sso-login-section"><div className="sso-divider"><span>或使用企业身份登录</span></div><div className="sso-provider-buttons">{ssoProviders.map((provider) => <button className="subtle sso-provider-button" type="button" key={provider.id} onClick={() => window.location.assign(ssoStartUrl(provider.id))}><Network size={16} /><span>{provider.name}</span><small>{provider.kind.toUpperCase()}</small></button>)}</div></div>}
<div className="auth-demo-note"><KeyRound size={14} /><span>本地开发演示账号默认密码:<strong>Demo@123456</strong></span></div>
</section>
<aside className="auth-access-map">
<span className="card-kicker">ACCESS MODEL</span><h2>页面访问由权限决定</h2>
<div><strong>普通创作者</strong><span>只进入被授予的工作台、项目、剧本、资产、生成、审片或交付页面。</span></div>
<div><strong>制片 / 组织管理员</strong><span>组织、工作区、成员、项目访问、模型、队列、用量、审计。</span></div>
<div><strong>系统管理员</strong><span>部署、存储、全局生成策略、功能开关、通知和 API 客户端。</span></div>
<p>默认使用真实 session 登录;仅在本地调试时显式设置 `AI_DRAMA_ALLOW_DEV_CONTEXT=1` 才会启用请求头上下文 bypass。</p>
</aside>
</main>
);
}
export function RegisterPage({ inviteToken, onRegistered }) {
const [invitation, setInvitation] = useState(null);
const [form, setForm] = useState({ displayName: "", password: "", confirmPassword: "" });
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
previewInvitation(inviteToken).then((result) => {
if (!cancelled) setInvitation(result.invitation);
}).catch((nextError) => {
if (!cancelled) setError(nextError.message);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [inviteToken]);
async function submit(event) {
event.preventDefault();
if (form.password !== form.confirmPassword) {
setError("两次输入的密码不一致");
return;
}
setBusy(true);
setError("");
try {
const result = await registerInvitedUser({ inviteToken, displayName: form.displayName, password: form.password });
await onRegistered?.(result);
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
return <main className="auth-page"><section className="auth-panel"><div className="auth-brand"><div className="brand-mark"><Film size={22} /></div><div><strong>AI 短剧生产平台</strong><span>Private production control plane</span></div></div><div className="auth-copy"><span className="card-kicker">INVITED WORKSPACE ACCESS</span><h1>加入生产组织</h1><p>{loading ? "正在验证邀请链接…" : invitation ? `${invitation.inviterName || "组织管理员"} 邀请你加入「${invitation.organizationName}」${invitation.workspaceName ? ` · ${invitation.workspaceName}` : ""}。` : "邀请链接无法使用。"}</p></div>{invitation && <form className="auth-form" onSubmit={submit}><label>工作显示名<input value={form.displayName} onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))} autoComplete="name" placeholder="例如:白编剧" required /></label><label>登录邮箱<input value={invitation.email} readOnly /></label><label>设置密码<input type="password" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} autoComplete="new-password" placeholder="至少 10 个字符" required /></label><label>确认密码<input type="password" value={form.confirmPassword} onChange={(event) => setForm((current) => ({ ...current, confirmPassword: event.target.value }))} autoComplete="new-password" required /></label>{error && <div className="auth-error"><AlertTriangle size={15} />{error}</div>}<button className="primary auth-submit" type="submit" disabled={busy}>{busy ? "创建账号中…" : "创建账号并加入"}</button></form>}{!invitation && !loading && <div className="auth-error"><AlertTriangle size={15} />{error || "请让组织管理员重新发送邀请。"}</div>}<div className="auth-demo-note"><KeyRound size={14} /><span>邀请注册会自动创建账号、加入组织并生成安全会话。</span></div></section><aside className="auth-access-map"><span className="card-kicker">ACCESS MODEL</span><h2>进入后仍按角色授权</h2><div><strong>邀请范围</strong><span>{invitation ? `${invitation.organizationName}${invitation.workspaceName ? ` / ${invitation.workspaceName}` : ""} · ${invitation.roleName || invitation.roleKey}` : "待验证"}</span></div><div><strong>权限边界</strong><span>注册链接只负责入组,剧本、资产、生成、审片和管理权限仍由服务端逐级校验。</span></div><p>链接有效期 7 天,使用成功后立即作废,平台不会保存明文邀请令牌。</p></aside></main>;
}
export function AccountSecurityPage({ authSession, platformContext, contextOverrides, onInvitationAccepted }) {
const [form, setForm] = useState({ currentPassword: "", nextPassword: "", confirmPassword: "" });
const [invitations, setInvitations] = useState([]);
const [sessions, setSessions] = useState([]);
const [devices, setDevices] = useState([]);
const [securityEvents, setSecurityEvents] = useState([]);
const [mfa, setMfa] = useState({ enabled: false, method: null });
const [mfaSetup, setMfaSetup] = useState(null);
const [mfaCode, setMfaCode] = useState("");
const [mfaPassword, setMfaPassword] = useState("");
const [showSessionHistory, setShowSessionHistory] = useState(false);
const [loading, setLoading] = useState(true);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [devicesLoading, setDevicesLoading] = useState(true);
const [securityEventsLoading, setSecurityEventsLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
async function loadInvitations() {
setLoading(true);
try {
const result = await fetchInvitations(contextOverrides);
setInvitations(result.invitations || []);
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setLoading(false);
}
}
async function loadSessions() {
setSessionsLoading(true);
try {
const result = await fetchAuthSessions();
setSessions(result.sessions || []);
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setSessionsLoading(false);
}
}
async function loadSecurityEvents() {
setSecurityEventsLoading(true);
try {
const result = await fetchSecurityEvents({ limit: 80 }, contextOverrides);
setSecurityEvents(result.events || []);
} catch (nextError) {
setError(nextError.message);
} finally {
setSecurityEventsLoading(false);
}
}
async function loadDevices() {
setDevicesLoading(true);
try {
const result = await fetchAuthDevices();
setDevices(result.devices || []);
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setDevicesLoading(false);
}
}
async function loadMfa() {
try {
const result = await fetchMfaStatus(contextOverrides);
setMfa(result);
if (result.enabled) setMfaSetup(null);
} catch (nextError) {
setError(nextError.message);
}
}
useEffect(() => { loadInvitations(); loadSessions(); loadDevices(); loadSecurityEvents(); loadMfa(); }, [contextOverrides?.organizationId, contextOverrides?.workspaceId]);
async function beginMfaSetup() {
setBusy(true);
try {
const result = await startMfaSetup(contextOverrides);
setMfaSetup(result.setup);
setMfaCode("");
setNotice("MFA 初始化密钥已生成,请在身份验证器中添加后输入验证码。");
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function confirmMfaSetup(event) {
event.preventDefault();
if (!mfaSetup || mfaCode.length !== 6) return;
setBusy(true);
try {
const result = await enableMfa({ methodId: mfaSetup.methodId, code: mfaCode }, contextOverrides);
setMfa(result);
setMfaSetup(null);
setMfaCode("");
setNotice("多因素认证已启用,之后登录需要密码和身份验证器验证码。");
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function cancelMfaInitialization() {
if (!mfaSetup) return;
setBusy(true);
try {
const result = await cancelMfaSetup({ methodId: mfaSetup.methodId }, contextOverrides);
setMfa(result);
setMfaSetup(null);
setMfaCode("");
setNotice("MFA 初始化已取消,未启用密钥已清理。");
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function removeMfa(event) {
event.preventDefault();
setBusy(true);
try {
const result = await disableMfa({ currentPassword: mfaPassword, code: mfaCode }, contextOverrides);
setMfa(result);
setMfaPassword("");
setMfaCode("");
setNotice("多因素认证已关闭。");
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function submitPassword(event) {
event.preventDefault();
if (form.nextPassword !== form.confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
setBusy(true);
try {
await changePassword({ currentPassword: form.currentPassword, nextPassword: form.nextPassword }, contextOverrides);
setForm({ currentPassword: "", nextPassword: "", confirmPassword: "" });
setNotice("密码已更新,当前登录会话保持有效。");
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function accept(invitation) {
setBusy(true);
try {
const result = await acceptInvitation(invitation.id, contextOverrides);
setInvitations((current) => current.filter((item) => item.id !== invitation.id));
setNotice(`已接受“${invitation.organization_name}”的邀请,正在切换到新组织。`);
await onInvitationAccepted?.(result.invitation);
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function revokeSession(session) {
setBusy(true);
try {
const result = await revokeAuthSession(session.id);
setSessions(result.sessions || []);
setNotice("已撤销选中的登录会话。");
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function revokeOthers() {
setBusy(true);
try {
const result = await revokeOtherAuthSessions();
setSessions(result.sessions || []);
setNotice(`已撤销 ${result.revokedCount || 0} 个其他登录会话。`);
await loadSecurityEvents();
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
async function setDeviceTrust(device, trusted) {
setBusy(true);
try {
const result = trusted ? await trustAuthDevice(device.id) : await untrustAuthDevice(device.id);
setDevices(result.devices || []);
setNotice(trusted ? `已将“${device.label || "浏览器设备"}”标记为信任设备。` : `已取消“${device.label || "浏览器设备"}”的信任状态。`);
await Promise.all([loadSessions(), loadSecurityEvents()]);
setError("");
} catch (nextError) {
setError(nextError.message);
} finally {
setBusy(false);
}
}
const expiresAt = authSession?.expiresAt || "";
const currentUser = platformContext?.context?.currentUser;
const visibleSessions = sessions.filter((session) => showSessionHistory || ["current", "active"].includes(session.status));
const activeSessionCount = sessions.filter((session) => ["current", "active"].includes(session.status)).length;
return (
<div className="enterprise-page account-page">
<AdminHeader eyebrow="ACCOUNT / SECURITY" title="账号与安全" description="管理当前用户的密码、登录会话和跨组织邀请。账号安全页属于个人范围,不会开放组织级管理操作。" action={<button className="subtle" onClick={loadInvitations}><RefreshCw size={15} />刷新邀请</button>} />
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
{error && <div className="inline-notice warn"><AlertTriangle size={15} />{error}</div>}
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card">
<SectionBar title="修改密码" detail="至少 12 个字符" />
<form className="enterprise-form-stack" onSubmit={submitPassword}>
<label>当前密码<input type="password" autoComplete="current-password" value={form.currentPassword} onChange={(event) => setForm({ ...form, currentPassword: event.target.value })} required /></label>
<label>新密码<input type="password" autoComplete="new-password" value={form.nextPassword} onChange={(event) => setForm({ ...form, nextPassword: event.target.value })} minLength={12} required /></label>
<label>确认新密码<input type="password" autoComplete="new-password" value={form.confirmPassword} onChange={(event) => setForm({ ...form, confirmPassword: event.target.value })} minLength={12} required /></label>
<button className="primary" type="submit" disabled={busy}><KeyRound size={15} />{busy ? "保存中…" : "更新密码"}</button>
</form>
</section>
<section className="studio-card">
<SectionBar title="当前会话" detail="Bearer session" />
<div className="account-session-card"><div className="account-avatar"><UserRoundCog size={22} /></div><div><strong>{currentUser?.display_name || "当前用户"}</strong><span>{currentUser?.email || "已登录用户"}</span><small>会话有效至 {expiresAt ? new Date(expiresAt).toLocaleString("zh-CN", { hour12: false }) : "当前浏览器会话"}</small></div></div>
<div className="policy-check-list account-policy-list"><div><ShieldCheck size={17} /><span>服务端 Bearer 鉴权</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>组织 / 工作区 / 项目 scope</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>前端隐藏不替代后端 403</span><StatusBadge status="enforced" label="已启用" /></div></div>
</section>
<section className="studio-card account-mfa-card">
<SectionBar title="多因素认证" detail={mfa.enabled ? "TOTP 已启用" : "建议为管理账号启用"} />
<div className="mfa-status-row"><div className="mfa-status-icon"><ShieldCheck size={19} /></div><div><strong>{mfa.enabled ? "身份验证器已保护当前账号" : "当前账号尚未启用 MFA"}</strong><span>{mfa.enabled ? `最近验证:${mfa.method?.lastUsedAt ? new Date(mfa.method.lastUsedAt).toLocaleString("zh-CN", { hour12: false }) : "尚未使用"}` : "启用后,密码泄露也不能直接进入生产平台。"}</span></div><StatusBadge status={mfa.enabled ? "enforced" : "needs-evidence"} label={mfa.enabled ? "已启用" : "未启用"} /></div>
{!mfa.enabled && !mfaSetup && <button className="primary full-width" onClick={beginMfaSetup} disabled={busy}><ShieldCheck size={15} />开始设置身份验证器</button>}
{mfaSetup && <form className="mfa-setup-form" onSubmit={confirmMfaSetup}><label>一次性密钥<code className="mfa-secret">{mfaSetup.secret}</code></label><label>验证器地址<textarea value={mfaSetup.otpauthUrl} readOnly rows="2" /></label><label>6 位验证码<input inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={mfaCode} onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="输入身份验证器当前验证码" required /></label><div className="form-actions"><button type="button" className="subtle" onClick={cancelMfaInitialization} disabled={busy}>取消</button><button className="primary" type="submit" disabled={busy || mfaCode.length !== 6}><CheckCircle2 size={15} />确认启用</button></div></form>}
{mfa.enabled && <form className="mfa-disable-form" onSubmit={removeMfa}><label>当前密码<input type="password" autoComplete="current-password" value={mfaPassword} onChange={(event) => setMfaPassword(event.target.value)} required /></label><label>身份验证器验证码<input inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={mfaCode} onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required /></label><button className="danger-button" type="submit" disabled={busy || mfaCode.length !== 6}>关闭 MFA</button></form>}
</section>
</div>
<section className="studio-card wide-card account-sessions-card">
<SectionBar title="登录设备与会话" detail={sessionsLoading ? "读取中…" : `${activeSessionCount} 个有效会话${showSessionHistory ? ` · ${sessions.length} 条记录` : ""}`} action={<div className="row-actions"><button className="subtle" onClick={() => setShowSessionHistory((current) => !current)}>{showSessionHistory ? "隐藏历史" : `查看历史(${sessions.filter((session) => !["current", "active"].includes(session.status)).length})`}</button><button className="subtle" onClick={loadSessions} disabled={sessionsLoading}><RefreshCw size={14} />刷新</button><button className="danger-button" onClick={revokeOthers} disabled={busy || sessions.filter((session) => session.status === "active").length === 0}>撤销其他会话</button></div>} />
<div className="account-session-list">{visibleSessions.map((session) => <div className="account-session-row" key={session.id}><div className="account-session-icon"><LogIn size={17} /></div><div className="account-session-main"><strong>{session.current ? "当前浏览器会话" : session.status === "active" ? "其他登录会话" : "历史会话"} · {session.device?.label || "未登记设备"}</strong><span>{session.userAgent || "本地客户端"} · {session.device?.status === "trusted" ? "信任设备" : session.device?.status === "revoked" ? "已撤销设备" : "普通设备"}</span><small>IP {session.ipAddress || "本机"} · 最近活动 {session.lastSeenAt ? new Date(session.lastSeenAt).toLocaleString("zh-CN", { hour12: false }) : "未知"} · 风险 {riskLevelLabel(session.riskLevel)}({session.riskScore})</small></div><StatusBadge status={riskBadgeStatus(session.riskLevel)} label={`风险 ${riskLevelLabel(session.riskLevel)}`} />{session.status === "active" && <button className="icon-text-button danger" onClick={() => revokeSession(session)} disabled={busy}><XCircle size={14} />撤销</button>}</div>)}{!sessionsLoading && !visibleSessions.length && <div className="empty-table">当前没有可显示的登录会话。</div>}</div>
</section>
<section className="studio-card wide-card account-devices-card">
<SectionBar title="设备信任与登录风险" detail={devicesLoading ? "读取中…" : `${devices.length} 台已登记设备`} action={<button className="subtle" onClick={loadDevices} disabled={devicesLoading}><RefreshCw size={14} />刷新设备</button>} />
<div className="device-risk-summary"><div><strong>{devices.filter((device) => device.status === "trusted").length}</strong><span>信任设备</span></div><div><strong>{devices.filter((device) => device.latestRiskLevel === "high").length}</strong><span>高风险设备</span></div><div><strong>{devices.reduce((count, device) => count + Number(device.activeSessionCount || 0), 0)}</strong><span>有效设备会话</span></div></div>
<div className="account-device-list">{devices.map((device) => <div className="account-device-row" key={device.id}><div className="account-device-icon"><ShieldCheck size={17} /></div><div className="account-device-main"><strong>{device.label || "浏览器设备"}</strong><span>{device.userAgent || "本地客户端"}</span><small>最近 IP {device.lastIpAddress || "本机"} · 最近活动 {device.lastSeenAt ? new Date(device.lastSeenAt).toLocaleString("zh-CN", { hour12: false }) : "未知"} · 首次登记 {device.firstSeenAt ? new Date(device.firstSeenAt).toLocaleString("zh-CN", { hour12: false }) : "未知"}</small></div><div className="account-device-state"><StatusBadge status={riskBadgeStatus(device.latestRiskLevel)} label={`风险 ${riskLevelLabel(device.latestRiskLevel)}(${device.latestRiskScore})`} /><StatusBadge status={device.status === "trusted" ? "enforced" : device.status === "revoked" ? "blocked" : "neutral"} label={deviceStatusLabel(device.status)} /><small>{device.activeSessionCount || 0} 个有效会话</small></div><div className="account-device-actions">{device.status === "trusted" ? <button className="icon-text-button" onClick={() => setDeviceTrust(device, false)} disabled={busy}><ShieldAlert size={14} />取消信任</button> : device.status !== "revoked" && <button className="icon-text-button" onClick={() => setDeviceTrust(device, true)} disabled={busy}><ShieldCheck size={14} />设为信任</button>}</div></div>)}{!devicesLoading && !devices.length && <div className="empty-table">登录后会自动登记当前浏览器设备。</div>}</div>
</section>
<section className="studio-card wide-card account-security-events-card">
<SectionBar title="账号安全事件" detail={securityEventsLoading ? "读取中…" : `${securityEvents.length} 条最近事件`} action={<button className="subtle" onClick={loadSecurityEvents} disabled={securityEventsLoading}><RefreshCw size={14} />刷新</button>} />
<div className="audit-enterprise-list compact-audit-list">{securityEvents.map((event) => <div key={event.id}><div className="audit-time">{formatAdminDate(event.createdAt)}</div><div className="audit-main"><strong>{securityEventLabel(event.eventType)}</strong><span>{securityEventSummary(event)}</span></div><div className="audit-actor">{event.ipAddress || "本机"}</div><StatusBadge status={securityResultTone(event.result)} label={securityResultLabel(event.result)} /></div>)}{!securityEventsLoading && !securityEvents.length && <div className="empty-table">还没有可显示的账号安全事件。</div>}</div>
</section>
<section className="studio-card wide-card account-invitations-card">
<SectionBar title="待接受的组织邀请" detail={loading ? "读取中…" : `${invitations.length} 个邀请`} />
<div className="account-invitation-list">{invitations.map((invitation) => <div className="account-invitation-row" key={invitation.id}><div><strong>{invitation.organization_name}</strong><span>{invitation.workspace_name || "组织级"} · {invitation.role_name || invitation.role_key}</span><small>{invitation.inviter_name || "组织管理员"} 邀请 · 有效至 {new Date(invitation.expires_at).toLocaleString("zh-CN", { hour12: false })}</small></div><button className="primary" onClick={() => accept(invitation)} disabled={busy}><CheckCircle2 size={15} />接受邀请</button></div>)}{!loading && !invitations.length && <div className="empty-table">当前邮箱没有待接受邀请。</div>}</div>
</section>
</div>
);
}
function StatusBadge({ status, label }) {
return <span className={`status-badge ${toneForStatus(status)}`}><span />{label || status}</span>;
}
function UsageTrendCard({ rows }) {
const trendRows = Array.isArray(rows) ? rows : [];
const totals = trendRows.reduce((summary, row) => ({
events: summary.events + Number(row.events || 0),
units: summary.units + Number(row.units || 0),
estimatedCost: summary.estimatedCost + Number(row.estimatedCost || 0)
}), { events: 0, units: 0, estimatedCost: 0 });
const peak = trendRows.reduce((current, row) => Number(row.estimatedCost || 0) > Number(current?.estimatedCost || 0) ? row : current, trendRows[0] || null);
const maxCost = Math.max(1, ...trendRows.map((row) => Number(row.estimatedCost || 0)));
return (
<section className="studio-card wide-card usage-trend-card">
<SectionBar title="用量趋势" detail={`${trendRows.length || 0} 天有记录 · 最近 31 天`} action={<StatusBadge status="active" label="服务端按日计量" />} />
{!trendRows.length ? <div className="empty-table">最近 31 天还没有可展示的用量事件。</div> : <>
<div className="usage-trend-summary">
<div><span>区间事件</span><strong>{totals.events}</strong></div>
<div><span>区间单位</span><strong>{totals.units}</strong></div>
<div><span>区间成本</span><strong>{totals.estimatedCost.toFixed(2)} CNY</strong></div>
<div><span>峰值日期</span><strong>{peak?.day?.slice(5) || "—"}</strong></div>
</div>
<div className="usage-trend-scroll" role="img" aria-label="最近 31 天按日用量成本趋势">
<div className="usage-trend-chart" style={{ gridTemplateColumns: `repeat(${trendRows.length}, minmax(24px, 1fr))`, minWidth: `${Math.max(320, trendRows.length * 30)}px` }}>
{trendRows.map((row) => {
const estimatedCost = Number(row.estimatedCost || 0);
const barHeight = estimatedCost ? Math.max(8, Math.round((estimatedCost / maxCost) * 100)) : 3;
return <div className="usage-trend-column" key={row.day} title={`${row.day} · ${estimatedCost.toFixed(2)} CNY · ${Number(row.events || 0)} 个事件`}><div className="usage-trend-bar-shell"><span className="usage-trend-bar" style={{ height: `${barHeight}%` }} /></div><span>{row.day?.slice(5) || "—"}</span><small>{estimatedCost.toFixed(0)}</small></div>;
})}
</div>
</div>
<div className="usage-trend-footer"><span><Activity size={13} />柱高表示日计量成本(CNY)</span><span>峰值 {peak?.day || "—"} · {Number(peak?.estimatedCost || 0).toFixed(2)} CNY</span></div>
</>}
</section>
);
}
export function AdminOverviewPage({ platformContext, setActiveTab }) {
const platform = platformContext?.platform || {};
const summary = platformContext?.summary || {};
const permissions = new Set(platformContext?.context?.permissions || []);
const canViewSystem = Boolean(platformContext?.context?.systemAdmin) || permissions.has("system:settings:view");
const canManageQueue = permissions.has("queue:manage");
const runners = platform.runnerHealth || [];
const members = platform.members || [];
const projects = platform.projects || [];
const usage = platform.usage || {};
const attention = runners.filter((runner) => runner.status !== "ready").length;
return (
<div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / OVERVIEW" title="管理概览" description="组织管理员在这里查看平台运行状态、团队协作、队列容量、用量和需要处理的治理事项。" action={<button className="subtle" onClick={() => window.location.reload()}><RefreshCw size={15} />刷新概览</button>} />
<div className="admin-kpi-grid">
<AdminKpi icon={Users} label="组织成员" value={members.length} detail="活跃成员与待邀请" tone="ok" />
<AdminKpi icon={Workflow} label="生产项目" value={projects.length} detail={`${summary.activeProjects ?? 0} 个制作中`} tone="neutral" />
<AdminKpi icon={Network} label="模型 / Runner" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${summary.runnerCount || runners.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} />
<AdminKpi icon={Gauge} label="队列深度" value={summary.queueDepth || 0} detail="本地任务等待处理" tone={summary.queueDepth ? "warn" : "ok"} />
</div>
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card wide-card">
<SectionBar title="平台运营状态" detail="当前组织 · 当前工作区" action={canManageQueue && <ContextLink onClick={() => setActiveTab("admin-queue")}>打开队列</ContextLink>} />
<div className="service-overview-list">
{runners.map((runner) => <div className="service-overview-row" key={runner.id}><div className="service-name"><span className={`service-dot ${runner.status === "ready" ? "ok" : "warn"}`} /><div><strong>{runner.name}</strong><span>{runner.lastHeartbeat || "local"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, 18 + runner.queueDepth * 16)}%` }} /></div><small>queue {runner.queueDepth}</small></div><StatusBadge status={runner.status} label={runner.status === "ready" ? "正常" : runner.status === "planned" ? "规划中" : "待接入模型"} /></div>)}
</div>
</section>
<section className="studio-card">
<SectionBar title="治理提醒" detail="高优先级事项" />
<div className="attention-list">
<div><ShieldAlert size={17} /><div><strong>外部云连接已禁用</strong><span>符合 local-runner-only 策略</span></div></div>
<div><AlertTriangle size={17} /><div><strong>声音授权证据缺失</strong><span>1 个策略需要补充材料</span></div></div>
<div><Clock3 size={17} /><div><strong>{summary.queueDepth || 0} 个任务排队</strong><span>可以调整队列并发或优先级</span></div></div>
</div>
{canViewSystem && <button className="subtle full-width" onClick={() => setActiveTab("system-generation")}><Settings2 size={15} />查看系统策略</button>}
</section>
<section className="studio-card">
<SectionBar title="额度使用" detail="当前计费周期" action={<ContextLink onClick={() => setActiveTab("admin-usage")}>成本中心</ContextLink>} />
<div className="usage-hero"><strong>{usage.totalCost ?? 86}</strong><span>本月本地计量成本</span></div>
{(usage.quotas || []).slice(0, 3).map((quota) => <div className="quota-row" key={`${quota.metric}-${quota.unit}`}><div><span>{quota.metric}</span><b>{quota.used_value}/{quota.limit_value} {quota.unit}</b></div><div className="quota-track"><span style={{ width: `${Math.min(100, Math.round((quota.used_value / quota.limit_value) * 100))}%` }} /></div></div>)}
</section>
</div>
</div>
);
}
export function AdminUsagePage({ contextOverrides, platformContext }) {
const organizationId = contextOverrides?.organizationId;
const permissions = new Set(platformContext?.context?.permissions || []);
const canManagePlan = permissions.has("billing:manage");
const canManageQuota = permissions.has("quota:manage");
const [data, setData] = useState({ billing: null, seat: {}, quotas: [], usage: { usage: [] } });
const [usageData, setUsageData] = useState({ items: [], summary: { eventCount: 0, totalUnits: 0, totalCost: 0, byKind: [] }, pagination: { page: 1, pageSize: 25, total: 0, totalPages: 0 }, facets: { workspaces: [], projects: [], users: [], kinds: [], costCenters: [] } });
const [invoiceData, setInvoiceData] = useState({ invoices: [], summary: { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 }, pagination: { page: 1, pageSize: 8, total: 0, totalPages: 0 } });
const [invoiceFilters, setInvoiceFilters] = useState({ status: "", query: "", page: 1, pageSize: 8 });
const [invoiceDetail, setInvoiceDetail] = useState(null);
const [usageFilters, setUsageFilters] = useState(() => {
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { from: formatDateInputValue(start), to: formatDateInputValue(today), workspaceId: "", projectId: "", userId: "", kind: "", costCenter: "", query: "", page: 1, pageSize: 25 };
});
const [planForm, setPlanForm] = useState({ planName: "", billingCycle: "monthly", currency: "CNY", baseFee: 0, seatUnitPrice: 0, storageUnitPrice: 0, clipUnitPrice: 0, seatLimit: 0, storageGb: 0, monthlyClipQuota: 0, quotaWarningPercent: 80, localRunnerOnly: true, cloudConnectorsRequireApproval: true });
const [quotaDrafts, setQuotaDrafts] = useState({});
const [costCenterDrafts, setCostCenterDrafts] = useState({});
const [loading, setLoading] = useState(true);
const [usageLoading, setUsageLoading] = useState(false);
const [busy, setBusy] = useState("");
const [notice, setNotice] = useState("");
async function loadUsage(nextFilters = usageFilters) {
if (!organizationId) return;
setUsageLoading(true);
try {
const result = await fetchOrganizationUsage(organizationId, nextFilters, contextOverrides);
setUsageData(result);
} catch (error) {
setNotice(error.message);
} finally {
setUsageLoading(false);
}
}
async function loadInvoices(nextFilters = invoiceFilters) {
if (!organizationId) return;
try {
const result = await fetchOrganizationInvoices(organizationId, nextFilters, contextOverrides);
setInvoiceData(result);
} catch (error) {
setNotice(error.message);
}
}
async function load() {
setLoading(true);
try {
const [result, usageResult, invoiceResult] = await Promise.all([
fetchOrganizationCommercial(organizationId, contextOverrides),
fetchOrganizationUsage(organizationId, usageFilters, contextOverrides),
fetchOrganizationInvoices(organizationId, invoiceFilters, contextOverrides)
]);
setData(result);
setUsageData(usageResult);
setInvoiceData(invoiceResult);
setInvoiceDetail(null);
setPlanForm({
planName: result.billing?.plan_name || "",
billingCycle: result.billing?.billing_cycle || "monthly",
currency: result.billing?.currency || "CNY",
baseFee: Number(result.billing?.base_fee || 0),
seatUnitPrice: Number(result.billing?.seat_unit_price || 0),
storageUnitPrice: Number(result.billing?.storage_unit_price || 0),
clipUnitPrice: Number(result.billing?.clip_unit_price || 0),
seatLimit: Number(result.billing?.seat_limit || 0),
storageGb: Number(result.billing?.storage_gb || 0),
monthlyClipQuota: Number(result.billing?.monthly_clip_quota || 0),
quotaWarningPercent: Number(result.billing?.quota_warning_percent || 80),
localRunnerOnly: Boolean(result.billing?.local_runner_only),
cloudConnectorsRequireApproval: Boolean(result.billing?.cloud_connectors_require_approval)
});
setQuotaDrafts(Object.fromEntries((result.quotas || []).map((quota) => [quota.id, quota.limitValue])));
setCostCenterDrafts(Object.fromEntries((result.costCenters || []).map((center) => [center.id, center.monthly_budget])));
setNotice("");
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { if (organizationId) load(); }, [organizationId]);
async function savePlan(event) {
event.preventDefault();
if (!canManagePlan) return;
setBusy("plan");
try {
const result = await updateOrganizationBilling(organizationId, planForm, contextOverrides);
setData(result);
setNotice("套餐与组织策略已保存,并已写入审计日志");
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function generateInvoice() {
if (!canManagePlan) return;
setBusy("invoice-generate");
try {
const result = await generateOrganizationInvoice(organizationId, { dueDays: 30, taxRate: 0 }, contextOverrides);
setInvoiceDetail(result);
await loadInvoices();
setNotice(result.idempotent ? "当前账期账单已存在,未重复生成" : "当前账期账单已生成草稿,并已写入审计日志");
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function openInvoice(invoiceId) {
setBusy(`invoice-${invoiceId}`);
try {
const result = await fetchOrganizationInvoice(organizationId, invoiceId, contextOverrides);
setInvoiceDetail(result);
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function changeInvoiceStatus(status) {
if (!canManagePlan || !invoiceDetail?.invoice?.id) return;
setBusy(`invoice-status-${status}`);
try {
const result = await updateOrganizationInvoiceStatus(organizationId, invoiceDetail.invoice.id, status, contextOverrides);
setInvoiceDetail(result);
await loadInvoices();
setNotice(`账单已变更为${result.invoice.statusLabel}`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function exportInvoices(format) {
try {
const filters = { ...invoiceFilters, page: 1, pageSize: 10000 };
if (format === "csv") {
const result = await exportOrganizationInvoicesCsv(organizationId, filters, contextOverrides);
downloadBrowserBlob(`invoices-${organizationId}-${new Date().toISOString().slice(0, 10)}.csv`, result.blob);
setNotice("账单台账已导出为 CSV");
} else {
const result = await exportOrganizationInvoices(organizationId, filters, contextOverrides);
downloadJson(`invoices-${organizationId}-${new Date().toISOString().slice(0, 10)}.json`, result);
setNotice("账单台账已导出为 JSON");
}
} catch (error) {
setNotice(error.message);
}
}
function submitInvoiceFilters(event) {
event.preventDefault();
const nextFilters = { ...invoiceFilters, page: 1 };
setInvoiceFilters(nextFilters);
loadInvoices(nextFilters);
}
function changeInvoicePage(page) {
const nextFilters = { ...invoiceFilters, page };
setInvoiceFilters(nextFilters);
loadInvoices(nextFilters);
}
async function saveQuota(quota) {
if (!canManageQuota) return;
const limitValue = Number(quotaDrafts[quota.id]);
setBusy(quota.id);
try {
const result = await updateQuotaAllocation(organizationId, quota.id, { limitValue }, contextOverrides);
setData(result);
setQuotaDrafts(Object.fromEntries((result.quotas || []).map((item) => [item.id, item.limitValue])));
setNotice(`${quota.workspaceName} / ${quota.metric} 配额已更新`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function saveCostCenter(center) {
if (!canManagePlan) return;
setBusy(center.id);
try {
const result = await updateCostCenter(organizationId, center.id, { monthlyBudget: Number(costCenterDrafts[center.id]) }, contextOverrides);
setData(result);
setCostCenterDrafts(Object.fromEntries((result.costCenters || []).map((item) => [item.id, item.monthly_budget])));
setNotice(`${center.name} 预算已更新`);
} catch (error) {
setNotice(error.message);
} finally {
setBusy("");
}
}
async function exportCommercial() {
try {
const result = await exportOrganizationCommercial(organizationId, contextOverrides);
downloadJson(`commercial-${organizationId}-${new Date().toISOString().slice(0, 10)}.json`, result);
setNotice("商业运营数据已导出为本地 JSON");
} catch (error) {
setNotice(error.message);
}
}
async function exportUsage(format) {
try {
const filters = { ...usageFilters, page: 1, pageSize: 10000 };
if (format === "csv") {
const result = await exportOrganizationUsageCsv(organizationId, filters, contextOverrides);
downloadBrowserBlob(`usage-${organizationId}-${new Date().toISOString().slice(0, 10)}.csv`, result.blob);
setNotice("用量明细已导出为 CSV");
} else {
const result = await exportOrganizationUsage(organizationId, filters, contextOverrides);
downloadJson(`usage-${organizationId}-${new Date().toISOString().slice(0, 10)}.json`, result);
setNotice("用量明细已导出为 JSON");
}
} catch (error) {
setNotice(error.message);
}
}
function submitUsageFilters(event) {
event.preventDefault();
const nextFilters = { ...usageFilters, page: 1 };
setUsageFilters(nextFilters);
loadUsage(nextFilters);
}
function resetUsageFilters() {
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const nextFilters = { from: formatDateInputValue(start), to: formatDateInputValue(today), workspaceId: "", projectId: "", userId: "", kind: "", costCenter: "", query: "", page: 1, pageSize: 25 };
setUsageFilters(nextFilters);
loadUsage(nextFilters);
}
function changeUsagePage(page) {
const nextFilters = { ...usageFilters, page };
setUsageFilters(nextFilters);
loadUsage(nextFilters);
}
function focusUsageByCostCenter(costCenter) {
const nextFilters = { ...usageFilters, costCenter, page: 1 };
setUsageFilters(nextFilters);
loadUsage(nextFilters);
document.getElementById("usage-detail-card")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function focusUsageByWorkspace(workspaceId) {
if (!workspaceId) return;
const nextFilters = { ...usageFilters, workspaceId, projectId: "", page: 1 };
setUsageFilters(nextFilters);
loadUsage(nextFilters);
document.getElementById("usage-detail-card")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
const seat = data.seat || {};
const clipQuota = (data.quotas || []).filter((quota) => quota.metric === "clip").reduce((sum, quota) => sum + quota.usedValue, 0);
const clipLimit = Number(data.billing?.monthly_clip_quota || 0);
const storageQuota = (data.quotas || []).filter((quota) => quota.metric === "storage").reduce((max, quota) => Math.max(max, quota.usedValue), 0);
const storageLimit = Number(data.billing?.storage_gb || 0);
const usagePagination = usageData.pagination || {};
const usageTotal = Number(usagePagination.total || 0);
const usagePage = Number(usagePagination.page || 1);
const usagePageSize = Number(usagePagination.pageSize || 25);
const usageStart = usageTotal ? (usagePage - 1) * usagePageSize + 1 : 0;
const usageEnd = Math.min(usagePage * usagePageSize, usageTotal);
const usageFacets = usageData.facets || { workspaces: [], projects: [], users: [], kinds: [], costCenters: [] };
const invoicePagination = invoiceData.pagination || {};
const invoiceTotal = Number(invoicePagination.total || 0);
const invoicePage = Number(invoicePagination.page || 1);
const invoicePageSize = Number(invoicePagination.pageSize || 8);
const invoiceStart = invoiceTotal ? (invoicePage - 1) * invoicePageSize + 1 : 0;
const invoiceEnd = Math.min(invoicePage * invoicePageSize, invoiceTotal);
return (
<div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / BILLING & QUOTAS" title="用量、席位与配额" description="组织管理员在这里管理席位预留、账单周期、配额预警、成本中心和本地执行策略。所有限制都在服务端执行。" action={<div className="header-actions"><button className="subtle" onClick={exportCommercial}><Download size={15} />导出运营数据</button><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新运营数据</button></div>} />
{notice && <div className={`inline-notice ${notice.includes("不能") || notice.includes("不足") || notice.includes("没有") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
{loading ? <div className="loading-state"><RefreshCw size={18} />读取组织商业运营数据…</div> : <>
<div className="admin-kpi-grid">
<AdminKpi icon={Users} label="席位" value={`${seat.active || 0}/${seat.limit || 0}`} detail={`${seat.pending || 0} 个邀请已预留`} tone={seat.remaining <= 0 ? "warn" : "ok"} />
<AdminKpi icon={Gauge} label="月度片段" value={`${clipQuota}/${clipLimit}`} detail="组织套餐上限" tone={clipLimit && clipQuota / clipLimit > .85 ? "warn" : "ok"} />
<AdminKpi icon={HardDrive} label="存储用量" value={`${storageQuota}/${storageLimit} GB`} detail="工作区已记录用量" tone={storageLimit && storageQuota / storageLimit > .85 ? "warn" : "ok"} />
<AdminKpi icon={CircleDollarSign} label="计量成本" value={data.usage?.totalCost ?? 0} detail="本月本地计量事件" tone="neutral" />
</div>
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card wide-card"><SectionBar title="组织套餐策略" detail={canManagePlan ? "可编辑" : "只读 · 需要账单管理权限"} /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐名称<input value={planForm.planName} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, planName: event.target.value })} /></label><label>账单周期<select value={planForm.billingCycle} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, billingCycle: event.target.value })}><option value="monthly">按月</option><option value="quarterly">按季度</option><option value="annual">按年</option></select></label><label>结算货币<input maxLength="3" value={planForm.currency} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, currency: event.target.value.toUpperCase() })} /></label><label>席位上限<input type="number" min="1" value={planForm.seatLimit} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatLimit: Number(event.target.value) })} /></label><label>存储上限(GB)<input type="number" min="1" value={planForm.storageGb} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageGb: Number(event.target.value) })} /></label><label>月度片段上限<input type="number" min="1" value={planForm.monthlyClipQuota} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, monthlyClipQuota: Number(event.target.value) })} /></label><label>配额预警阈值(%)<input type="number" min="50" max="99" value={planForm.quotaWarningPercent} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, quotaWarningPercent: Number(event.target.value) })} /></label><label className="checkbox-field"><input type="checkbox" checked={planForm.localRunnerOnly} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, localRunnerOnly: event.target.checked })} />只允许本地 Runner</label><label className="checkbox-field"><input type="checkbox" checked={planForm.cloudConnectorsRequireApproval} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, cloudConnectorsRequireApproval: event.target.checked })} />外部连接器必须审批</label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存套餐策略"}</button></div></form></section>
<section className="studio-card"><SectionBar title="席位状态" detail="活跃成员 + 待处理邀请" /><div className="commercial-seat-meter"><div><span>已占用</span><strong>{seat.active || 0}</strong></div><div><span>待入组</span><strong>{seat.pending || 0}</strong></div><div><span>可用</span><strong>{seat.remaining || 0}</strong></div></div><div className="quota-track"><span style={{ width: `${Math.min(100, seat.utilization || 0)}%` }} /></div><p className="muted-copy">新邀请和 SSO/SCIM 入组都会经过席位检查;达到上限时由后端返回 `409 seat_limit_reached`。</p></section>
</div>
<section className="studio-card wide-card"><SectionBar title="工作区配额" detail={`${(data.quotas || []).length} 条配额 · 工作区额度不能超过组织套餐`} /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>工作区</span><span>指标</span><span>已用</span><span>上限</span><span>剩余</span><span>周期</span><span>操作</span></div>{(data.quotas || []).map((quota) => <div className="enterprise-table-row" key={quota.id}><strong>{quota.workspaceName}</strong><span>{quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric}</span><span>{quota.usedValue} {quota.unit}</span><label className="quota-edit-input"><input type="number" min={quota.usedValue} value={quotaDrafts[quota.id] ?? quota.limitValue} disabled={!canManageQuota} onChange={(event) => setQuotaDrafts({ ...quotaDrafts, [quota.id]: event.target.value })} /><span>{quota.unit}</span></label><span>{quota.remainingValue} {quota.unit}</span><span>{String(quota.period_end || "").slice(0, 10)}</span><button className="icon-text-button" onClick={() => saveQuota(quota)} disabled={!canManageQuota || busy === quota.id}><Save size={13} />{busy === quota.id ? "保存中" : "保存"}</button></div>)}{!(data.quotas || []).length && <div className="empty-table">当前组织还没有工作区配额。</div>}</div></section>
<div className="enterprise-grid enterprise-grid-main commercial-alert-grid"><section className="studio-card"><SectionBar title="配额预警" detail={`${(data.quotaWarnings || []).length} 个需要关注`} /><div className="policy-check-list">{(data.quotaWarnings || []).map((warning) => <button className="policy-check-row" type="button" key={`${warning.metric}-${warning.workspaceId || "org"}`} onClick={() => focusUsageByWorkspace(warning.workspaceId)}><AlertTriangle size={17} /><span>{warning.workspaceName || "组织"} · {warning.label}</span><StatusBadge status={warning.status === "critical" ? "failed" : "needs-evidence"} label={`${warning.utilization}%`} /></button>)}{!(data.quotaWarnings || []).length && <div className="empty-table">当前没有达到预警阈值的额度。</div>}</div></section><section className="studio-card"><SectionBar title="账期与变更记录" detail={`${data.billing?.billing_cycle || "monthly"} · 下一账期 ${String(data.billing?.next_invoice_at || "").slice(0, 10) || "未设置"}`} /><div className="audit-enterprise-list compact-audit-list">{(data.billingHistory || []).slice(0, 5).map((event) => <div key={event.id}><div className="audit-time">{formatAdminDate(event.created_at)}</div><div className="audit-main"><strong>{event.event_type}</strong><span>{event.actor_name || "system"}</span></div><StatusBadge status="active" label="已记录" /></div>)}{!(data.billingHistory || []).length && <div className="empty-table">还没有套餐变更记录。</div>}</div></section></div>
<section className="studio-card wide-card"><SectionBar title="成本中心" detail="按组织运营预算与实际计量拆分" /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>成本中心</span><span>本月使用</span><span>月度预算</span><span>剩余</span><span>利用率</span><span>操作</span></div>{(data.costCenters || []).map((center) => <div className="enterprise-table-row" key={center.id}><strong>{center.name}</strong><span>{center.used} {center.currency}</span><label className="quota-edit-input"><input type="number" min="0" value={costCenterDrafts[center.id] ?? center.monthly_budget} disabled={!canManagePlan} onChange={(event) => setCostCenterDrafts({ ...costCenterDrafts, [center.id]: event.target.value })} /><span>{center.currency}</span></label><span>{center.remaining} {center.currency}</span><StatusBadge status={center.utilization >= 100 ? "failed" : center.utilization >= 80 ? "needs-evidence" : "active"} label={`${center.utilization}%`} /><button className="icon-text-button" onClick={() => saveCostCenter(center)} disabled={!canManagePlan || busy === center.id}><Save size={13} />{busy === center.id ? "保存中" : "保存"}</button></div>)}{!(data.costCenters || []).length && <div className="empty-table">当前组织没有成本中心。</div>}</div><div className="commercial-usage-list cost-center-detail-list">{(data.costCenterDetail || []).slice(0, 8).map((item) => <div key={`${item.code}-${item.workspaceId || "org"}`}><div><strong>{item.code}</strong><span>{item.workspaceName}</span></div><b>{item.units}</b><em>{item.cost} CNY · {item.events} 事件</em><button className="icon-text-button" type="button" onClick={() => focusUsageByCostCenter(item.code)}><ArrowUpRight size={13} />查看明细</button></div>)}</div></section>
<section className="studio-card wide-card"><SectionBar title="账单计价参数" detail="发票生成使用的本地计价快照" /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐固定费<input type="number" min="0" step="0.01" value={planForm.baseFee} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, baseFee: Number(event.target.value) })} /></label><label>席位单价<input type="number" min="0" step="0.01" value={planForm.seatUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatUnitPrice: Number(event.target.value) })} /></label><label>存储单价 / GB<input type="number" min="0" step="0.01" value={planForm.storageUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageUnitPrice: Number(event.target.value) })} /></label><label>片段单价<input type="number" min="0" step="0.01" value={planForm.clipUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, clipUnitPrice: Number(event.target.value) })} /></label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存计价参数"}</button></div></form></section>
<UsageTrendCard rows={data.usageTrend} />
<section className="studio-card wide-card invoice-ledger-card" id="invoice-ledger-card">
<SectionBar title="账单台账" detail={`${invoiceTotal} 张账单 · 服务端状态机`} action={<div className="header-actions"><button className="subtle" type="button" onClick={() => exportInvoices("json")}><Download size={14} />导出 JSON</button><button className="subtle" type="button" onClick={() => exportInvoices("csv")}><Download size={14} />导出 CSV</button>{canManagePlan && <button className="primary" type="button" onClick={generateInvoice} disabled={busy === "invoice-generate"}><Plus size={14} />{busy === "invoice-generate" ? "生成中…" : "生成当前账期"}</button>}</div>} />
<div className="usage-detail-summary invoice-summary"><div><span>账单总数</span><strong>{invoiceData.summary?.count || 0}</strong></div><div><span>草稿</span><strong>{invoiceData.summary?.draft || 0}</strong></div><div><span>待收金额</span><strong>{Number((invoiceData.summary?.issued || 0) + (invoiceData.summary?.overdue || 0))} 张</strong></div><div><span>非作废金额</span><strong>{Number(invoiceData.summary?.amount || 0).toFixed(2)} {data.billing?.currency || "CNY"}</strong></div></div>
<form className="commercial-usage-filters invoice-filters" onSubmit={submitInvoiceFilters}><label>状态<select value={invoiceFilters.status} onChange={(event) => setInvoiceFilters({ ...invoiceFilters, status: event.target.value })}><option value="">全部状态</option><option value="draft">草稿</option><option value="issued">已开票</option><option value="paid">已支付</option><option value="overdue">已逾期</option><option value="void">已作废</option></select></label><label className="usage-filter-search">关键词<input value={invoiceFilters.query} placeholder="发票号或状态" onChange={(event) => setInvoiceFilters({ ...invoiceFilters, query: event.target.value })} /></label><div className="usage-filter-actions"><button className="primary" type="submit"><Search size={14} />查询</button><button className="subtle" type="button" onClick={() => { const nextFilters = { status: "", query: "", page: 1, pageSize: 8 }; setInvoiceFilters(nextFilters); loadInvoices(nextFilters); }}>重置</button></div></form>
<div className="enterprise-table invoice-table"><div className="enterprise-table-head"><span>账单号</span><span>账期</span><span>状态</span><span>金额</span><span>到期日</span><span>操作</span></div>{invoiceData.invoices.map((invoice) => <div className="enterprise-table-row" key={invoice.id}><button className="table-link" type="button" onClick={() => openInvoice(invoice.id)}><strong>{invoice.invoiceNumber}</strong><small>{invoice.billingCycle} · {invoice.currency}</small></button><span>{String(invoice.periodStart || "").slice(0, 10)} ~ {String(invoice.periodEnd || "").slice(0, 10)}</span><StatusBadge status={invoice.status === "paid" ? "active" : invoice.status === "overdue" ? "failed" : invoice.status === "void" ? "disabled" : invoice.status === "issued" ? "enforced" : "needs-evidence"} label={invoice.statusLabel} /><strong className="table-emphasis">{Number(invoice.totalAmount || 0).toFixed(2)} {invoice.currency}</strong><span>{String(invoice.dueAt || "").slice(0, 10) || "—"}</span><button className="icon-text-button" type="button" onClick={() => openInvoice(invoice.id)} disabled={busy === `invoice-${invoice.id}`}><ArrowUpRight size={13} />详情</button></div>)}{!invoiceData.invoices.length && <div className="empty-table">当前组织还没有账单。管理员可以生成当前账期草稿。</div>}</div>
<div className="audit-pagination"><span>显示 {invoiceStart}-{invoiceEnd} / {invoiceTotal}</span><div><button className="icon-only-button" type="button" title="上一页" aria-label="上一页" onClick={() => changeInvoicePage(invoicePage - 1)} disabled={invoicePage <= 1}><ChevronLeft size={16} /></button><button className="icon-only-button" type="button" title="下一页" aria-label="下一页" onClick={() => changeInvoicePage(invoicePage + 1)} disabled={invoicePage >= (invoicePagination.totalPages || 0)}><ChevronRight size={16} /></button></div></div>
{invoiceDetail?.invoice && <div className="invoice-detail-panel"><SectionBar title={`账单详情 · ${invoiceDetail.invoice.invoiceNumber}`} detail={`${invoiceDetail.invoice.statusLabel} · 创建人 ${invoiceDetail.invoice.createdByName || "system"}`} action={<button className="icon-only-button" type="button" title="关闭账单详情" aria-label="关闭账单详情" onClick={() => setInvoiceDetail(null)}><XCircle size={17} /></button>} /><div className="invoice-detail-summary"><div><span>账期</span><strong>{String(invoiceDetail.invoice.periodStart || "").slice(0, 10)} ~ {String(invoiceDetail.invoice.periodEnd || "").slice(0, 10)}</strong></div><div><span>小计</span><strong>{Number(invoiceDetail.invoice.subtotal || 0).toFixed(2)} {invoiceDetail.invoice.currency}</strong></div><div><span>税额</span><strong>{Number(invoiceDetail.invoice.taxAmount || 0).toFixed(2)} {invoiceDetail.invoice.currency}</strong></div><div><span>应付合计</span><strong>{Number(invoiceDetail.invoice.totalAmount || 0).toFixed(2)} {invoiceDetail.invoice.currency}</strong></div></div><div className="invoice-line-list">{(invoiceDetail.lines || []).map((line) => <div className="invoice-line-row" key={line.id}><div><strong>{line.description}</strong><span>{line.quantity} {line.unitName} · 单价 {Number(line.unitPrice || 0).toFixed(2)} {invoiceDetail.invoice.currency}</span></div><b>{Number(line.amount || 0).toFixed(2)} {invoiceDetail.invoice.currency}</b></div>)}</div><div className="form-actions invoice-actions">{canManagePlan && invoiceDetail.invoice.status === "draft" && <><button className="primary" type="button" onClick={() => changeInvoiceStatus("issued")} disabled={busy === "invoice-status-issued"}><FileText size={14} />开票</button><button className="subtle" type="button" onClick={() => changeInvoiceStatus("void")} disabled={busy === "invoice-status-void"}><Ban size={14} />作废</button></>}{canManagePlan && invoiceDetail.invoice.status === "issued" && <><button className="primary" type="button" onClick={() => changeInvoiceStatus("paid")} disabled={busy === "invoice-status-paid"}><CheckCircle2 size={14} />标记已支付</button><button className="subtle" type="button" onClick={() => changeInvoiceStatus("overdue")} disabled={busy === "invoice-status-overdue"}><Clock3 size={14} />标记逾期</button><button className="subtle" type="button" onClick={() => changeInvoiceStatus("void")} disabled={busy === "invoice-status-void"}><Ban size={14} />作废</button></>}{canManagePlan && invoiceDetail.invoice.status === "overdue" && <><button className="primary" type="button" onClick={() => changeInvoiceStatus("paid")} disabled={busy === "invoice-status-paid"}><CheckCircle2 size={14} />补记已支付</button><button className="subtle" type="button" onClick={() => changeInvoiceStatus("void")} disabled={busy === "invoice-status-void"}><Ban size={14} />作废</button></>}</div></div>}
</section>
<section className="studio-card wide-card usage-detail-card" id="usage-detail-card">
<SectionBar title="用量事件明细" detail={`${usageTotal} 条匹配事件 · 服务端分页`} action={<div className="header-actions"><button className="subtle" type="button" onClick={() => exportUsage("json")}><Download size={14} />导出 JSON</button><button className="subtle" type="button" onClick={() => exportUsage("csv")}><Download size={14} />导出 CSV</button></div>} />
<form className="commercial-usage-filters" onSubmit={submitUsageFilters}>
<label>开始日期<input type="date" value={usageFilters.from} onChange={(event) => setUsageFilters({ ...usageFilters, from: event.target.value })} /></label>
<label>结束日期<input type="date" value={usageFilters.to} onChange={(event) => setUsageFilters({ ...usageFilters, to: event.target.value })} /></label>
<label>工作区<select value={usageFilters.workspaceId} onChange={(event) => setUsageFilters({ ...usageFilters, workspaceId: event.target.value, projectId: "" })}><option value="">全部工作区</option>{usageFacets.workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label>
<label>项目<select value={usageFilters.projectId} onChange={(event) => setUsageFilters({ ...usageFilters, projectId: event.target.value })}><option value="">全部项目</option>{usageFacets.projects.filter((project) => !usageFilters.workspaceId || project.workspace_id === usageFilters.workspaceId).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></label>
<label>操作者<select value={usageFilters.userId} onChange={(event) => setUsageFilters({ ...usageFilters, userId: event.target.value })}><option value="">全部用户</option>{usageFacets.users.map((user) => <option key={user.id} value={user.id}>{user.display_name} · {user.email}</option>)}</select></label>
<label>事件类型<select value={usageFilters.kind} onChange={(event) => setUsageFilters({ ...usageFilters, kind: event.target.value })}><option value="">全部类型</option>{usageFacets.kinds.map((kind) => <option key={kind} value={kind}>{kind}</option>)}</select></label>
<label>成本中心<select value={usageFilters.costCenter} onChange={(event) => setUsageFilters({ ...usageFilters, costCenter: event.target.value })}><option value="">全部成本中心</option>{usageFacets.costCenters.map((center) => <option key={center.code} value={center.code}>{center.name} · {center.code}</option>)}</select></label>
<label className="usage-filter-search">关键词<input value={usageFilters.query} placeholder="类型、项目、用户或元数据" onChange={(event) => setUsageFilters({ ...usageFilters, query: event.target.value })} /></label>
<div className="usage-filter-actions"><button className="primary" type="submit" disabled={usageLoading}><Search size={14} />查询</button><button className="subtle" type="button" onClick={resetUsageFilters} disabled={usageLoading}>重置</button></div>
</form>
<div className="usage-detail-summary"><div><span>事件数</span><strong>{usageData.summary?.eventCount || 0}</strong></div><div><span>计量单位</span><strong>{usageData.summary?.totalUnits || 0}</strong></div><div><span>估算成本</span><strong>{Number(usageData.summary?.totalCost || 0).toFixed(2)} CNY</strong></div><div><span>当前页</span><strong>{usagePage}/{usagePagination.totalPages || 0}</strong></div></div>
<div className="enterprise-table commercial-usage-table">
<div className="enterprise-table-head"><span>发生时间</span><span>事件</span><span>工作区 / 项目</span><span>操作者</span><span>计量</span><span>成本</span><span>元数据</span></div>
{usageLoading && <div className="loading-state"><RefreshCw size={17} />正在读取用量明细…</div>}
{!usageLoading && usageData.items.map((item) => <div className="enterprise-table-row" key={item.id}><span>{formatAdminDate(item.createdAt)}</span><div className="usage-event-cell"><strong>{item.kind}</strong><small>{item.unitName}</small></div><div className="usage-event-cell"><strong>{item.workspaceName}</strong><small>{item.projectName}</small></div><div className="usage-event-cell"><strong>{item.userName}</strong><small>{item.userEmail || item.userId || "系统"}</small></div><span>{item.units} {item.unitName}</span><strong className="table-emphasis">{Number(item.estimatedCost || 0).toFixed(2)} CNY</strong><span className="usage-metadata-cell">{item.costCenter} · {Object.keys(item.metadata || {}).length ? JSON.stringify(item.metadata).slice(0, 76) : "—"}</span></div>)}
{!usageLoading && !usageData.items.length && <div className="empty-table">当前筛选条件没有用量事件。</div>}
</div>
<div className="audit-pagination"><span>显示 {usageStart}-{usageEnd} / {usageTotal}</span><div><button className="icon-only-button" type="button" title="上一页" aria-label="上一页" onClick={() => changeUsagePage(usagePage - 1)} disabled={usageLoading || usagePage <= 1}><ChevronLeft size={16} /></button><button className="icon-only-button" type="button" title="下一页" aria-label="下一页" onClick={() => changeUsagePage(usagePage + 1)} disabled={usageLoading || usagePage >= (usagePagination.totalPages || 0)}><ChevronRight size={16} /></button></div></div>
</section>
<div className="enterprise-grid enterprise-grid-main"><section className="studio-card"><SectionBar title="策略边界" detail="当前组织生效" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>本地 Runner 优先</span><StatusBadge status={data.billing?.local_runner_only ? "enforced" : "needs-evidence"} label={data.billing?.local_runner_only ? "已启用" : "已放开"} /></div><div><ShieldCheck size={17} /><span>外部连接器审批</span><StatusBadge status={data.billing?.cloud_connectors_require_approval ? "enforced" : "needs-evidence"} label={data.billing?.cloud_connectors_require_approval ? "必须审批" : "无需审批"} /></div><div><ShieldCheck size={17} /><span>席位预留校验</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>服务端配额阻断</span><StatusBadge status="enforced" label="已启用" /></div></div></section><section className="studio-card"><SectionBar title="用量分类" detail="当前筛选范围" /><div className="commercial-usage-list">{(usageData.summary?.byKind || []).slice(0, 6).map((item) => <div key={`${item.kind}-${item.unitName}`}><div><strong>{item.kind}</strong><span>{item.events} 事件 · {item.unitName}</span></div><b>{item.units}</b><em>{Number(item.estimatedCost || 0).toFixed(2)} CNY</em></div>)}{!(usageData.summary?.byKind || []).length && <div className="empty-table">暂无分类汇总</div>}</div></section></div>
</>}
</div>
);
}
export function AdminQueuePage({ platformContext, contextOverrides }) {
const [queue, setQueue] = useState({ jobs: [], runners: [], summary: {} });
const [workerInfo, setWorkerInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
const [notice, setNotice] = useState("");
const [selectedJob, setSelectedJob] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedJobIds, setSelectedJobIds] = useState([]);
const canManageRunners = Boolean(platformContext?.context?.systemAdmin || platformContext?.context?.permissions?.includes("runner:manage"));
async function load() {
setLoading(true);
try {
const [nextQueue, nextWorker] = await Promise.all([fetchAdminQueue(contextOverrides), fetchWorkerStatus(contextOverrides)]);
setQueue(nextQueue);
setWorkerInfo(nextWorker.worker || null);
setNotice("");
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [contextOverrides.organizationId, contextOverrides.workspaceId]);
const jobs = useMemo(() => filter === "all" ? queue.jobs : queue.jobs.filter((job) => job.status === filter), [filter, queue.jobs]);
function jobContext(job) {
return { ...contextOverrides, projectId: job.project_id || contextOverrides.projectId };
}
async function showJobDetail(job) {
setDetailLoading(true);
try {
const result = await fetchJob(job.id, jobContext(job));
setSelectedJob(result.job);
} catch (error) {
setNotice(error.message);
} finally {
setDetailLoading(false);
}
}
async function runJobAction(job, action, body = {}) {
try {
const scoped = jobContext(job);
if (action === "run") {
const approveExternal = job.cost_mode !== "local" && Boolean(job.approval_required);
const result = await runJob(job.id, { approveExternal }, scoped);
setQueue((current) => ({ ...current, jobs: current.jobs.map((item) => item.id === job.id ? result.job : item) }));
setNotice(`任务 ${job.id} ${approveExternal ? "已批准并提交执行" : "已提交执行"},状态:${result.job.status}`);
setSelectedJob(result.job);
return;
}
const result = action === "retry"
? await retryJob(job.id, scoped)
: action === "cancel"
? await cancelJob(job.id, scoped)
: await updateJobPriority(job.id, body.priority, scoped);
setQueue((current) => ({ ...current, jobs: current.jobs.map((item) => item.id === job.id ? { ...item, ...result.job } : item) }));
setSelectedJob((current) => current?.id === job.id ? { ...current, ...result.job } : current);
setNotice(action === "retry" ? `任务 ${job.id} 已重新排队` : action === "cancel" ? `任务 ${job.id} 已取消` : `任务 ${job.id} 优先级已调整为 ${body.priority}`);
} catch (error) {
setNotice(error.message);
}
}
async function runRunnerAction(runner, action) {
try {
const result = await operateRunner(runner.serviceKey || runner.id, action, contextOverrides);
setQueue((current) => ({ ...current, runners: result.runners || current.runners }));
setNotice(`${runner.name} 已记录“${action === "pause" ? "暂停" : action === "resume" ? "恢复" : action === "drain" ? "排空" : action === "restart" ? "重启请求" : "心跳刷新"}”操作`);
} catch (error) {
setNotice(error.message);
}
}
async function dispatchWorkerNow() {
try {
const result = await dispatchWorker(contextOverrides);
setWorkerInfo(result.worker || null);
await load();
setNotice(result.worker?.result?.jobId ? `Worker 已领取任务 ${result.worker.result.jobId}` : "Worker 已检查队列,目前没有可自动执行的本地任务");
} catch (error) {
setNotice(error.message);
}
}
async function runBatchAction(action) {
if (!selectedJobIds.length) return;
try {
const result = await batchQueueAction({ action, jobIds: selectedJobIds }, contextOverrides);
setSelectedJobIds([]);
await load();
setNotice(`批量${action === "retry" ? "重试" : action === "cancel" ? "取消" : "调整"}完成:${result.successCount} 成功,${result.failureCount} 失败`);
} catch (error) {
setNotice(error.message);
}
}
return (
<div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / QUEUE" title="任务队列与 Runner" description="查看生成任务的优先级、状态、失败尝试和本地执行器容量。生产任务在这里进入可追踪的运营闭环。" action={<div className="header-actions"><StatusBadge status={workerInfo?.healthStatus === "stale" ? "failed" : workerInfo?.enabled ? (workerInfo?.inFlight ? "running" : "ready") : "paused"} label={workerInfo?.healthStatus === "stale" ? "Worker 心跳失效" : workerInfo?.enabled ? `Worker ${workerInfo?.inFlight ? "执行中" : "在线"}` : "Worker 已暂停"} /><button className="subtle" onClick={dispatchWorkerNow}><Zap size={15} />立即领取</button><button className="subtle" onClick={load}><RefreshCw size={15} />刷新队列</button></div>} />
{notice && <div className="inline-notice warn"><AlertTriangle size={15} />{notice}</div>}
<div className="admin-kpi-grid compact-kpis"><AdminKpi icon={Clock3} label="排队" value={queue.summary.queued || 0} detail="等待 Runner" tone="warn" /><AdminKpi icon={Activity} label="运行中" value={queue.summary.running || 0} detail="正在执行" tone="ok" /><AdminKpi icon={XCircle} label="失败" value={queue.summary.failed || 0} detail="需要重试或处理" tone={queue.summary.failed ? "warn" : "neutral"} /><AdminKpi icon={CheckCircle2} label="已完成" value={queue.summary.completed || 0} detail="本工作区历史任务" tone="ok" /></div>
<div className="worker-status-strip"><div><strong>本地后台 Worker</strong><span>{workerInfo?.workerId || "local-worker"} · 并发 {workerInfo?.maxConcurrency || 0} · 轮询 {workerInfo?.pollMs || 0}ms</span></div><div><span>可自动领取</span><strong>{workerInfo?.queueDepth ?? 0} 个本地就绪任务</strong></div><div><span>最近心跳</span><strong>{workerInfo?.lastHeartbeatAt ? workerInfo.lastHeartbeatAt.replace("T", " ").slice(0, 19) : "等待启动"}</strong></div><div><span>队列告警</span><strong>{workerInfo?.queueAlert?.level === "critical" ? "严重" : workerInfo?.queueAlert?.level === "warning" ? "预警" : "正常"}</strong></div></div>
<section className="studio-card wide-card">
<SectionBar title="任务列表" detail={loading ? "读取中…" : `${jobs.length} 条任务`} action={<div className="table-tools"><ListFilter size={15} /><select value={filter} onChange={(event) => setFilter(event.target.value)}><option value="all">全部状态</option><option value="queued">排队</option><option value="running">运行中</option><option value="failed">失败</option><option value="completed">完成</option></select>{selectedJobIds.length > 0 && <><button className="icon-text-button" onClick={() => runBatchAction("retry")}><RefreshCw size={13} />批量重试</button><button className="icon-text-button danger" onClick={() => runBatchAction("cancel")}><XCircle size={13} />批量取消</button></>}</div>} />
<div className="enterprise-table queue-table">
<div className="enterprise-table-head"><span>选择</span><span>任务</span><span>项目 / 镜头</span><span>适配器</span><span>优先级</span><span>尝试</span><span>状态</span><span>创建者</span><span>操作</span></div>
{jobs.length ? jobs.map((job) => <div className="enterprise-table-row" key={job.id}><label className="table-checkbox"><input type="checkbox" checked={selectedJobIds.includes(job.id)} onChange={(event) => setSelectedJobIds((current) => event.target.checked ? [...current, job.id] : current.filter((id) => id !== job.id))} aria-label={`选择任务 ${job.id}`} /></label><strong>{job.kind}</strong><span>{job.project_name} · {job.shot_id || "全局"}</span><span className="mono">{job.adapter_id}</span><label className="queue-priority"><input aria-label={`${job.id} 优先级`} type="number" min="1" max="100" defaultValue={job.priority} onBlur={(event) => { const value = Number(event.target.value); if (value !== job.priority) runJobAction(job, "priority", { priority: value }); }} /></label><span>{job.attempts}</span><StatusBadge status={job.status} label={job.status === "queued" ? "排队" : job.status === "blocked" ? "已阻塞" : job.status === "failed" ? "失败" : job.status === "completed" ? "完成" : job.status === "cancelled" ? "取消" : job.status} /><span>{job.creator_name || job.created_by}</span><div className="queue-actions"><button className="icon-text-button" onClick={() => showJobDetail(job)}><FileText size={13} />日志</button>{["queued", "blocked", "failed", "cancelled"].includes(job.status) && <button className="icon-text-button" onClick={() => runJobAction(job, "run")}><Play size={13} />{job.cost_mode !== "local" && job.approval_required ? "批准并执行" : "执行"}</button>}{["failed", "cancelled"].includes(job.status) && <button className="icon-text-button" onClick={() => runJobAction(job, "retry")}><RefreshCw size={13} />重试</button>}{["queued", "running"].includes(job.status) && <button className="icon-text-button danger" onClick={() => runJobAction(job, "cancel")}><XCircle size={13} />取消</button>}</div></div>) : <div className="empty-table">{loading ? "正在读取本地队列…" : "当前工作区没有任务。"}</div>}
</div>
</section>
{selectedJob && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setSelectedJob(null)}><section className="production-modal queue-detail-modal"><div className="production-modal-head"><div><span className="card-kicker">EXECUTION LOG</span><h3>{selectedJob.kind} · {selectedJob.id}</h3></div><button className="icon-only-button" onClick={() => setSelectedJob(null)} title="关闭执行日志" aria-label="关闭执行日志"><XCircle size={16} /></button></div>{detailLoading ? <div className="loading-state">读取执行日志…</div> : <><div className="generation-detail-grid"><div><span>项目</span><strong>{selectedJob.project_name || selectedJob.project_id}</strong></div><div><span>状态</span><StatusBadge status={selectedJob.status} /></div><div><span>适配器</span><strong className="mono">{selectedJob.adapter_id}</strong></div><div><span>尝试次数</span><strong>{selectedJob.attempts || selectedJob.attemptLog?.length || 0}</strong></div></div><div className="attempt-log-list">{selectedJob.attemptLog?.map((attempt) => <div key={attempt.id}><strong>Attempt {attempt.attempt_number}</strong><span>{attempt.runner_id}</span><StatusBadge status={attempt.status} /><small>{attempt.started_at || attempt.created_at} {attempt.finished_at ? `→ ${attempt.finished_at}` : ""}</small>{attempt.error_message && <p>{attempt.error_message}</p>}</div>) || <div className="empty-table">没有执行尝试记录。</div>}</div><div className="generation-json-grid"><div><span>请求合同</span><pre>{JSON.stringify(selectedJob.request || {}, null, 2)}</pre></div><div><span>结果证据</span><pre>{JSON.stringify(selectedJob.result || {}, null, 2)}</pre></div></div></>}</section></div>}
<section className="studio-card wide-card">
<SectionBar title="Runner 容量" detail="执行器健康状态" />
<div className="runner-capacity-grid">{(queue.runners || []).map((runner) => <div key={runner.id}><div><strong>{runner.name}</strong><StatusBadge status={runner.status} label={runner.status === "ready" ? "正常" : runner.status === "paused" ? "已暂停" : runner.status === "draining" ? "排空中" : runner.status === "starting" ? "启动中" : "待接入"} /></div><span>queue {runner.queueDepth}</span><div className="quota-track"><span style={{ width: `${Math.min(100, 14 + runner.queueDepth * 14)}%` }} /></div><small>{runner.lastHeartbeat}</small>{canManageRunners && <div className="runner-actions"><button className="icon-text-button" onClick={() => runRunnerAction(runner, runner.status === "paused" || runner.status === "draining" ? "resume" : "pause")}><ServerCog size={13} />{runner.status === "paused" || runner.status === "draining" ? "恢复" : "暂停"}</button><button className="icon-text-button" onClick={() => runRunnerAction(runner, "restart")}><RefreshCw size={13} />重启</button><button className="icon-text-button" onClick={() => runRunnerAction(runner, "heartbeat")}><Activity size={13} />心跳</button></div>}</div>)}</div>
</section>
</div>
);
}
function emptyModelConnectorForm() {
return { label: "", endpoint: "http://127.0.0.1:7860", kind: "http-json", capability: "image-to-video", costMode: "local", approvalRequired: false, authEnv: "", protocolJson: "" };
}
function modelConnectorForm(model) {
return {
label: model.label || "",
endpoint: model.endpoint || "",
kind: model.kind || "http-json",
capability: (model.capability || []).join(", "),
costMode: model.costMode || model.cost_mode || "local",
approvalRequired: Boolean(model.approvalRequired ?? model.approval_required),
authEnv: model.auth_env || "",
protocolJson: JSON.stringify(model.protocol || {}, null, 2)
};
}
function ModelConnectorFields({ form, setForm, widePrefix = "" }) {
const external = form.costMode !== "local";
return <>
<label>连接器名称<input required value={form.label} onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))} placeholder="例如:自有图生视频平台" /></label>
<label>接口地址<input required value={form.endpoint} onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))} placeholder="http://127.0.0.1:7860/api/generate" /></label>
<label>协议<select value={form.kind} onChange={(event) => setForm((current) => ({ ...current, kind: event.target.value }))}><option value="http-json">自定义 HTTP JSON</option><option value="openai-compatible">OpenAI-compatible</option><option value="comfyui">ComfyUI optional</option><option value="openai-compatible-audio">OpenAI-compatible 音频</option></select></label>
<label>能力标签<input value={form.capability} onChange={(event) => setForm((current) => ({ ...current, capability: event.target.value }))} placeholder="image-to-video, first-last-frame" /></label>
<label>密钥环境变量<input value={form.authEnv} onChange={(event) => setForm((current) => ({ ...current, authEnv: event.target.value }))} placeholder="例如 NEWAPI_API_KEY(不保存密钥本身)" /></label>
<label>成本策略<select value={form.costMode} onChange={(event) => setForm((current) => ({ ...current, costMode: event.target.value, approvalRequired: event.target.value === "local" ? current.approvalRequired : true }))}><option value="local">本地</option><option value="mixed">混合</option><option value="cloud">外部云(需审批)</option></select></label>
<label className="checkbox-field"><input type="checkbox" checked={form.approvalRequired} disabled={external} onChange={(event) => setForm((current) => ({ ...current, approvalRequired: event.target.checked }))} />{external ? "需要审批后使用(外部/混合强制)" : "需要审批后使用"}</label>
<label className={`model-editor-wide ${widePrefix}`}>协议路由 / 模型配置 JSON<textarea value={form.protocolJson} onChange={(event) => setForm((current) => ({ ...current, protocolJson: event.target.value }))} placeholder={'例如:{"routes":{"image":"images/generations"},"models":{"image":"local-image"}}'} /></label>
</>;
}
export function AdminModelsPage({ platformContext, contextOverrides, onModelsChanged }) {
const platform = platformContext?.platform || {};
const [models, setModels] = useState(platform.modelRegistry || []);
const [form, setForm] = useState(emptyModelConnectorForm);
const [editingModel, setEditingModel] = useState(null);
const [editForm, setEditForm] = useState(emptyModelConnectorForm);
const [notice, setNotice] = useState("");
useEffect(() => setModels(platform.modelRegistry || []), [platform.modelRegistry]);
function syncModels(nextModels) {
setModels(nextModels);
onModelsChanged?.(nextModels);
}
async function submit(event) {
event.preventDefault();
try {
let protocol = {};
if (form.protocolJson.trim()) {
try { protocol = JSON.parse(form.protocolJson); } catch { setNotice("协议配置 JSON 无效,请检查括号和引号"); return; }
}
const result = await registerModel({ ...form, authEnv: form.authEnv.trim(), protocol, capability: form.capability.split(",").map((item) => item.trim()).filter(Boolean) }, contextOverrides);
syncModels(result.models || [result.model, ...models]);
setForm((current) => ({ ...current, label: "" }));
setNotice("模型连接器已写入当前组织");
} catch (error) {
setNotice(error.message);
}
}
function openEditor(model) {
setEditingModel(model);
setEditForm(modelConnectorForm(model));
setNotice("");
}
async function saveEdit(event) {
event.preventDefault();
if (!editingModel) return;
try {
let protocol = {};
if (editForm.protocolJson.trim()) {
try { protocol = JSON.parse(editForm.protocolJson); } catch { setNotice("协议配置 JSON 无效,请检查括号和引号"); return; }
}
const result = await updateModel(editingModel.id, { ...editForm, authEnv: editForm.authEnv.trim(), protocol, capability: editForm.capability.split(",").map((item) => item.trim()).filter(Boolean) }, contextOverrides);
const nextModels = models.map((item) => item.id === editingModel.id ? result.model : item);
syncModels(nextModels);
setEditingModel(null);
setNotice("模型连接器配置已更新,密钥仍只通过环境变量引用");
} catch (error) {
setNotice(error.message);
}
}
async function probe(model) {
try {
const result = await probeModel(model.id, contextOverrides);
syncModels(result.models || models);
setNotice(result.model.status === "ready" ? `${model.label} 探活成功${result.model.latency_ms ? `,${result.model.latency_ms}ms` : ""}` : `${model.label} 探活失败:${result.message || result.model.error_message || "连接器不可用"}`);
} catch (error) {
setNotice(error.message);
}
}
async function toggle(model) {
try {
const nextStatus = model.status === "paused" ? "ready" : "paused";
const result = await updateModel(model.id, { status: nextStatus }, contextOverrides);
const nextModels = models.map((item) => item.id === model.id ? result.model : item);
syncModels(nextModels);
setNotice(`${model.label} 已${nextStatus === "ready" ? "启用" : "暂停"}`);
} catch (error) {
setNotice(error.message);
}
}
return (
<div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / MODEL OPS" title="模型与 Runner 中台" description="组织级管理本地模型平台、OpenAI-compatible 接口和可选 ComfyUI 桥接,所有连接器都要有能力标签、成本策略和审批状态。" />
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
<section className="studio-card wide-card">
<SectionBar title="连接器注册表" detail={`${models.length} 个连接器`} action={<StatusBadge status="active" label="默认 local-only" />} />
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{models.map((model) => <div className="enterprise-table-row" key={model.id}><strong>{model.label}</strong><span>{(model.capability || []).join(" · ")}</span><span className="mono">{model.endpoint}<small className="model-probe-meta">{model.last_probe_at ? `最近探活 ${model.latency_ms || "-"}ms` : "尚未探活"}</small></span><span>{model.costMode || model.cost_mode}</span><span>{model.approvalRequired || model.approval_required ? "需要审批" : "无需"}</span><StatusBadge status={model.status} label={model.status === "not-connected" ? "未连接" : model.status === "optional" ? "可选" : model.status === "error" ? "错误" : model.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => openEditor(model)}><Settings2 size={13} />编辑</button><button className="icon-text-button" onClick={() => probe(model)}><Activity size={13} />检测</button>{["ready", "paused"].includes(model.status) && <button className="icon-text-button" onClick={() => toggle(model)}><ServerCog size={13} />{model.status === "paused" ? "启用" : "暂停"}</button>}</div></div>)}</div>
</section>
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card wide-card"><SectionBar title="注册新连接器" detail="只保存连接信息,不直接调用外部服务" /><form className="enterprise-form-grid" onSubmit={submit}><ModelConnectorFields form={form} setForm={setForm} widePrefix="register-model-fields" /><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />注册连接器</button></div></form></section>
<section className="studio-card"><SectionBar title="连接策略" detail="当前组织生效" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>自有模型平台优先</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>单画面生成契约</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldAlert size={17} /><span>云端节点调用</span><StatusBadge status="disabled" label="默认禁用" /></div><div><Network size={17} /><span>ComfyUI 桥接</span><StatusBadge status="optional" label="可选" /></div></div></section>
</div>
{editingModel && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingModel(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="model-edit-title"><div className="production-modal-head"><div><span className="card-kicker">MODEL CONNECTOR</span><h3 id="model-edit-title">编辑连接器 · {editingModel.label}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingModel(null)} title="关闭编辑连接器" aria-label="关闭编辑连接器"><X size={16} /></button></div><p className="muted-copy">修改会写入当前组织的连接器注册表。平台只保存环境变量名称,不会把 API Key 写入数据库。</p><form className="enterprise-form-grid" onSubmit={saveEdit}><ModelConnectorFields form={editForm} setForm={setEditForm} widePrefix="edit-model-fields" /><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingModel(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存连接器</button></div></form></section></div>}
</div>
);
}
const auditResultLabels = {
ok: "成功",
pass: "通过",
success: "成功",
failure: "失败",
error: "错误",
blocked: "已阻断",
"requires-approval": "需审批",
challenge: "挑战"
};
function auditResultLabel(result) {
return auditResultLabels[result] || result || "未知";
}
function auditScopeLabel(scope) {
if (scope?.mode === "global") return "全局审计视图";
if (scope?.mode === "organization") return "当前组织全部工作区";
if (scope?.mode === "project") return "当前项目及组织级事件";
return "当前工作区及组织级事件";
}
function auditInitialState(platformContext) {
const initial = platformContext?.platform?.auditLog || [];
return {
auditLog: initial,
pagination: { page: 1, pageSize: 25, total: initial.length, totalPages: 1, hasMore: false },
facets: { actions: [], targetTypes: [], results: [], actors: [] },
scope: { mode: platformContext?.context?.systemAdmin ? "global" : "organization" }
};
}
export function AdminAuditPage({ platformContext, contextOverrides }) {
const [filters, setFilters] = useState({ page: 1, pageSize: 25, query: "", action: "", targetType: "", targetId: "", result: "", actorUserId: "", from: "", to: "" });
const [draft, setDraft] = useState(filters);
const [data, setData] = useState(() => auditInitialState(platformContext));
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [notice, setNotice] = useState("");
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
async function load(nextFilters = filters) {
setLoading(true);
setNotice("");
try {
const result = await fetchAuditEvents(nextFilters, contextOverrides);
setData(result);
setFilters(nextFilters);
setDraft(nextFilters);
} catch (error) {
setNotice(error.message || "审计记录读取失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
load(filters);
}, []);
async function openDetail(id) {
setSelectedId(id);
setDetail(null);
setDetailLoading(true);
try {
setDetail(await fetchAuditEvent(id, contextOverrides));
} catch (error) {
setNotice(error.message || "审计详情读取失败");
} finally {
setDetailLoading(false);
}
}
async function handleExport() {
setExporting(true);
setNotice("");
try {
const result = await exportAuditEvents({ ...filters, pageSize: 5000 }, contextOverrides);
downloadJson(`audit-${new Date().toISOString().slice(0, 10)}.json`, result);
setNotice(`已导出 ${result.total ?? result.auditLog?.length ?? 0} 条审计记录`);
} catch (error) {
setNotice(error.message || "审计导出失败");
} finally {
setExporting(false);
}
}
function updateDraft(key, value) {
setDraft((current) => ({ ...current, [key]: value }));
}
function submitFilters(event) {
event.preventDefault();
load({ ...draft, page: 1 });
}
function clearFilters() {
const cleared = { page: 1, pageSize: filters.pageSize || 25, query: "", action: "", targetType: "", targetId: "", result: "", actorUserId: "", from: "", to: "" };
setDraft(cleared);
load(cleared);
}
const pagination = data.pagination || { page: 1, pageSize: 25, total: data.auditLog?.length || 0, totalPages: 1, hasMore: false };
const canGoPrevious = pagination.page > 1;
const canGoNext = Boolean(pagination.hasMore);
const events = data.auditLog || [];
const selectedEvent = detail?.event;
return <div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / AUDIT" title="审计与合规中心" description="服务端分页的组织治理事件流,覆盖权限变更、模型调用、生成任务、QA、交付和账号安全关联证据。" action={<div className="header-actions"><StatusBadge status="active" label={auditScopeLabel(data.scope)} /><button className="subtle" onClick={() => load(filters)} disabled={loading}><RefreshCw size={15} />{loading ? "读取中…" : "刷新"}</button><button className="primary" onClick={handleExport} disabled={exporting || loading}><Download size={15} />{exporting ? "导出中…" : "导出 JSON"}</button></div>} />
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
<section className="studio-card wide-card audit-filter-card">
<SectionBar title="审计筛选" detail="筛选在服务端执行,结果不会泄露当前权限范围之外的事件" action={<ListFilter size={17} />} />
<form className="audit-filter-grid" onSubmit={submitFilters}>
<label className="audit-filter-search"><span>关键词</span><div className="search-box"><Search size={15} /><input value={draft.query} onChange={(event) => updateDraft("query", event.target.value)} placeholder="动作、对象、操作者、元数据" /></div></label>
<label><span>动作</span><select value={draft.action} onChange={(event) => updateDraft("action", event.target.value)}><option value="">全部动作</option>{(data.facets?.actions || []).map((item) => <option key={item.value} value={item.value}>{item.value}({item.count})</option>)}</select></label>
<label><span>对象类型</span><select value={draft.targetType} onChange={(event) => updateDraft("targetType", event.target.value)}><option value="">全部对象</option>{(data.facets?.targetTypes || []).map((item) => <option key={item.value} value={item.value}>{item.value}({item.count})</option>)}</select></label>
<label><span>结果</span><select value={draft.result} onChange={(event) => updateDraft("result", event.target.value)}><option value="">全部结果</option>{(data.facets?.results || []).map((item) => <option key={item.value} value={item.value}>{auditResultLabel(item.value)}({item.count})</option>)}</select></label>
<label><span>操作者</span><select value={draft.actorUserId} onChange={(event) => updateDraft("actorUserId", event.target.value)}><option value="">全部操作者</option>{(data.facets?.actors || []).map((item) => <option key={item.value} value={item.value}>{item.label}({item.count})</option>)}</select></label>
<label><span>对象 ID</span><input value={draft.targetId} onChange={(event) => updateDraft("targetId", event.target.value)} placeholder="例如 shot-01" /></label>
<label><span>开始日期</span><input type="date" value={draft.from} onChange={(event) => updateDraft("from", event.target.value)} /></label>
<label><span>结束日期</span><input type="date" value={draft.to} onChange={(event) => updateDraft("to", event.target.value)} /></label>
<div className="audit-filter-actions"><button className="primary" type="submit" disabled={loading}><Search size={15} />应用筛选</button><button className="subtle" type="button" onClick={clearFilters} disabled={loading}>清空</button></div>
</form>
</section>
<div className={`audit-workbench-grid${selectedId ? " has-detail" : ""}`}>
<section className="studio-card wide-card">
<SectionBar title="事件流" detail={`${pagination.total} 条记录 · 第 ${pagination.page} / ${pagination.totalPages} 页`} action={<StatusBadge status="active" label="append-only audit" />} />
<div className="audit-enterprise-list audit-server-list">
{events.map((item) => <button type="button" className={`audit-event-row${selectedId === item.id ? " selected" : ""}`} key={item.id} onClick={() => openDetail(item.id)}>
<div className="audit-time">{formatAdminDate(item.created_at)}</div>
<div className="audit-main"><strong>{item.action}</strong><span>{item.target_type} · {item.target_id}</span><small>{item.organization_name || item.organizationName || ""}{item.workspace_name ? ` / ${item.workspace_name}` : ""}{item.project_name ? ` / ${item.project_name}` : ""}</small></div>
<div className="audit-actor">{item.actor_name || item.actorName || item.actor_user_id || "system"}<small>{item.actor_email || ""}</small></div>
<StatusBadge status={item.result} label={auditResultLabel(item.result)} />
</button>)}
{!loading && !events.length && <div className="empty-table">没有匹配的审计事件。</div>}
{loading && <div className="loading-state"><RefreshCw size={18} />正在读取审计事件…</div>}
</div>
<div className="audit-pagination"><span>显示 {(pagination.page - 1) * pagination.pageSize + (events.length ? 1 : 0)}-{Math.min(pagination.page * pagination.pageSize, pagination.total)} / {pagination.total}</span><div><button className="icon-only-button" type="button" title="上一页" aria-label="上一页" onClick={() => load({ ...filters, page: pagination.page - 1 })} disabled={!canGoPrevious || loading}><ChevronLeft size={16} /></button><button className="icon-only-button" type="button" title="下一页" aria-label="下一页" onClick={() => load({ ...filters, page: pagination.page + 1 })} disabled={!canGoNext || loading}><ChevronRight size={16} /></button></div></div>
</section>
{selectedId && <aside className="studio-card audit-detail-panel">
<SectionBar title="事件详情" detail={selectedId} action={<button className="icon-only-button" type="button" title="关闭事件详情" aria-label="关闭事件详情" onClick={() => { setSelectedId(""); setDetail(null); }}><X size={16} /></button>} />
{detailLoading && <div className="loading-state"><RefreshCw size={18} />正在读取详情…</div>}
{selectedEvent && <div className="audit-detail-content"><div className="audit-detail-heading"><div><span className="card-kicker">{selectedEvent.action}</span><h3>{selectedEvent.target_type} · {selectedEvent.target_id}</h3></div><StatusBadge status={selectedEvent.result} label={auditResultLabel(selectedEvent.result)} /></div><dl className="audit-detail-meta"><div><dt>发生时间</dt><dd>{formatAdminDate(selectedEvent.created_at)}</dd></div><div><dt>操作者</dt><dd>{selectedEvent.actor_name || selectedEvent.actor_user_id || "system"}{selectedEvent.actor_email ? ` · ${selectedEvent.actor_email}` : ""}</dd></div><div><dt>组织范围</dt><dd>{selectedEvent.organization_name || selectedEvent.organizationName || "全局"}{selectedEvent.workspace_name ? ` / ${selectedEvent.workspace_name}` : ""}{selectedEvent.project_name ? ` / ${selectedEvent.project_name}` : ""}</dd></div></dl><div className="audit-detail-block"><strong>操作元数据</strong><pre>{JSON.stringify(selectedEvent.metadata || {}, null, 2)}</pre></div><div className="audit-detail-block"><strong>同一对象的关联审计</strong>{(detail.relatedAudit || []).slice(0, 8).map((item) => <div className="audit-related-row" key={item.id}><span>{formatAdminDate(item.created_at)}</span><strong>{item.action}</strong><StatusBadge status={item.result} label={auditResultLabel(item.result)} /></div>)}{!detail.relatedAudit?.length && <div className="empty-table">没有关联审计事件。</div>}</div><div className="audit-detail-block"><strong>关联账号安全事件</strong>{(detail.relatedSecurityEvents || []).slice(0, 8).map((item) => <div className="audit-related-row" key={item.id}><span>{formatAdminDate(item.created_at)}</span><strong>{item.event_type}</strong><StatusBadge status={item.result} label={auditResultLabel(item.result)} /></div>)}{!detail.relatedSecurityEvents?.length && <div className="empty-table">没有关联账号安全事件。</div>}</div></div>}
</aside>}
</div>
<div className="enterprise-grid enterprise-grid-main"><section className="studio-card"><SectionBar title="合规策略" detail="平台硬约束" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>一图一画面</span><StatusBadge status="enforced" label="阻断级" /></div><div><ShieldCheck size={17} /><span>角色 / 场景连续性</span><StatusBadge status="enforced" label="阻断级" /></div><div><AlertTriangle size={17} /><span>声音授权证据</span><StatusBadge status="needs-evidence" label="待补证据" /></div></div></section><section className="studio-card"><SectionBar title="审计能力" detail="当前权限范围" /><div className="policy-check-list"><div><ListChecks size={17} /><span>服务端分页与筛选</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>组织 / 系统管理员隔离</span><StatusBadge status="enforced" label="已启用" /></div><div><Download size={17} /><span>JSON / CSV 归档</span><StatusBadge status="active" label="可用" /></div></div></section></div>
</div>;
}
function formatAdminDate(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false });
}
function downloadBrowserBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function formatDateInputValue(date) {
return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
}
const securityEventLabels = {
"account.created": "账号创建",
"account.locked": "账号锁定",
"login.success": "登录成功",
"login.failure": "登录失败",
"login.blocked": "登录被阻断",
"mfa.challenge.created": "MFA 挑战",
"mfa.enrollment.challenge.created": "MFA 绑定挑战",
"mfa.challenge.success": "MFA 验证成功",
"mfa.challenge.failure": "MFA 验证失败",
"mfa.setup.started": "开始设置 MFA",
"mfa.setup.cancelled": "取消 MFA 设置",
"mfa.enabled": "启用 MFA",
"mfa.disabled": "关闭 MFA",
"mfa.reset": "管理员重置 MFA",
"session.created": "创建登录会话",
"session.revoked": "撤销登录会话",
"session.logout": "退出登录",
"password.changed": "修改密码",
"password.failure": "密码验证失败",
"password.reset": "管理员重置密码"
};
function securityEventLabel(eventType) {
return securityEventLabels[eventType] || eventType || "安全事件";
}
function securityResultTone(result) {
if (result === "success") return "active";
if (result === "challenge") return "needs-evidence";
if (result === "blocked") return "blocked";
return "failed";
}
function securityResultLabel(result) {
return result === "success" ? "成功" : result === "challenge" ? "挑战" : result === "blocked" ? "阻断" : "失败";
}
function securityEventSummary(event) {
const metadata = event?.metadata || {};
if (metadata.reason === "unknown_email") return `邮箱 ${metadata.email || "未识别"}`;
if (metadata.reason === "lockout_active" || metadata.reason === "failed_login_threshold") return metadata.lockedUntil ? `锁定至 ${formatAdminDate(metadata.lockedUntil)}` : "达到失败次数阈值";
if (metadata.reason === "invalid_code" || metadata.reason === "invalid_setup_code" || metadata.reason === "invalid_disable_code") return "验证码不正确";
if (metadata.reason === "current_password_invalid") return "当前密码不正确";
if (metadata.method) return `方式:${metadata.method}`;
if (metadata.authMethod) return `方式:${metadata.authMethod}`;
if (metadata.reason) return String(metadata.reason);
return event?.userAgent || "身份安全事件";
}
function riskBadgeStatus(level) {
return level === "high" ? "blocked" : level === "low" ? "active" : "neutral";
}
function riskLevelLabel(level) {
return level === "high" ? "高" : level === "low" ? "低" : "中";
}
function deviceStatusLabel(status) {
return status === "trusted" ? "信任设备" : status === "revoked" ? "已撤销" : "普通设备";
}
function systemUserStatusLabel(status) {
return status === "active" ? "正常" : status === "suspended" ? "已停用" : "待激活";
}
export function SystemUsersPage({ contextOverrides }) {
const [data, setData] = useState({ users: [], summary: {}, filters: {} });
const [queryInput, setQueryInput] = useState("");
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [busyId, setBusyId] = useState("");
const [notice, setNotice] = useState("");
const [createForm, setCreateForm] = useState({ displayName: "", email: "", password: "", systemAdmin: false, organizationId: "", organizationRoleKey: "org_member", workspaceId: "", workspaceRoleKey: "writer", projectId: "", projectRoleKey: "project_editor" });
const [createOpen, setCreateOpen] = useState(false);
const [membershipForm, setMembershipForm] = useState({ organizationId: "", organizationRoleKey: "org_member", workspaceId: "", workspaceRoleKey: "writer", projectId: "", projectRoleKey: "project_editor" });
async function load(nextQuery = query, nextStatus = status) {
setLoading(true);
try {
const result = await fetchSystemUsers(contextOverrides, { query: nextQuery, status: nextStatus, limit: 160 });
setData(result);
setNotice("");
if (selectedId && !result.users.some((user) => user.id === selectedId)) {
setSelectedId("");
setDetail(null);
}
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
async function openDetail(userId) {
setSelectedId(userId);
setDetailLoading(true);
try {
setDetail(await fetchSystemUser(userId, contextOverrides));
} catch (error) {
setNotice(error.message);
} finally {
setDetailLoading(false);
}
}
useEffect(() => { load("", ""); }, [contextOverrides?.userId, contextOverrides?.organizationId]);
async function submitSearch(event) {
event.preventDefault();
setQuery(queryInput.trim());
await load(queryInput.trim(), status);
}
async function setUserStatus(user) {
const nextStatus = user.status === "suspended" ? "active" : "suspended";
const action = nextStatus === "suspended" ? "停用" : "恢复";
if (!window.confirm(`确认${action}“${user.displayName}”吗?${nextStatus === "suspended" ? "停用后该用户的全部登录会话会立即失效。" : "恢复后需要重新登录。"}`)) return;
setBusyId(user.id);
try {
const result = await updateSystemUser(user.id, { status: nextStatus }, contextOverrides);
setDetail(result);
await load();
setNotice(`${user.displayName}已${action}${result.revokedSessionCount ? `,撤销 ${result.revokedSessionCount} 个会话` : ""}`);
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function revokeSessions(user) {
if (!window.confirm(`确认撤销“${user.displayName}”的全部登录会话吗?`)) return;
setBusyId(user.id);
try {
const result = await revokeSystemUserSessions(user.id, contextOverrides);
setDetail(result);
await load();
setNotice(`已撤销 ${result.revokedSessionCount || 0} 个登录会话`);
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function createUser(event) {
event.preventDefault();
setBusyId("create");
try {
const payload = { ...createForm };
if (!payload.password) delete payload.password;
if (!payload.organizationId) delete payload.organizationId;
if (!payload.workspaceId) delete payload.workspaceId;
if (!payload.projectId) delete payload.projectId;
const result = await createSystemUser(payload, contextOverrides);
await load();
setCreateForm({ displayName: "", email: "", password: "", systemAdmin: false, organizationId: "", organizationRoleKey: "org_member", workspaceId: "", workspaceRoleKey: "writer", projectId: "", projectRoleKey: "project_editor" });
setCreateOpen(false);
setNotice(result.temporaryPassword ? `账号已创建。一次性初始密码:${result.temporaryPassword},请立即交付给用户并要求登录后修改。` : "账号已创建并写入审计日志");
if (result.user?.id) await openDetail(result.user.id);
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function resetPassword(user) {
if (!window.confirm(`确认重置“${user.displayName}”的密码吗?原有会话会全部失效。`)) return;
setBusyId(user.id);
try {
const result = await resetSystemUserPassword(user.id, {}, contextOverrides);
setDetail(result);
setNotice(result.temporaryPassword ? `密码已重置。一次性密码:${result.temporaryPassword}` : "密码已重置,用户需要重新登录。");
await load();
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function resetMfa(user) {
if (!window.confirm(`确认清除“${user.displayName}”的 MFA 绑定吗?该用户下次登录需要重新绑定。`)) return;
setBusyId(user.id);
try {
const result = await resetSystemUserMfa(user.id, contextOverrides);
setDetail(result);
setNotice(`MFA 已重置,撤销 ${result.revokedSessionCount || 0} 个会话`);
await load();
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function saveMemberships(event) {
event.preventDefault();
if (!detail?.user?.id) return;
setBusyId(detail.user.id);
try {
const payload = { ...membershipForm };
if (!payload.organizationId) delete payload.organizationId;
if (!payload.workspaceId) delete payload.workspaceId;
if (!payload.projectId) delete payload.projectId;
const result = await updateSystemUserMemberships(detail.user.id, payload, contextOverrides);
setDetail(result);
await load();
setNotice("组织、工作区或项目归属已更新");
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
async function toggleSystemAdmin(user) {
const enabled = !user.systemAdmin;
if (!window.confirm(`确认${enabled ? "授予" : "移除"}“${user.displayName}”的系统管理员权限吗?`)) return;
setBusyId(user.id);
try {
const result = await updateSystemUser(user.id, { systemAdmin: enabled }, contextOverrides);
setDetail(result);
await load();
setNotice(`系统管理员权限已${enabled ? "授予" : "移除"}`);
} catch (error) {
setNotice(error.message);
} finally {
setBusyId("");
}
}
const users = data.users || [];
const organizations = [...new Map(users.flatMap((user) => user.organizations || []).map((item) => [item.id, item])).values()];
const workspaces = [...new Map(users.flatMap((user) => user.workspaces || []).map((item) => [item.id, item])).values()];
const projects = [...new Map(users.flatMap((user) => user.projects || []).map((item) => [item.id, item])).values()];
return (
<div className="enterprise-page">
<AdminHeader
eyebrow="SYSTEM SETTINGS / USER GOVERNANCE"
title="全局用户目录"
description="跨组织管理账号生命周期、身份安全和登录会话。组织管理员只能管理本组织成员,系统管理员才能访问这里。"
action={<button className="subtle" onClick={() => load()} disabled={loading}><RefreshCw size={15} />刷新目录</button>}
/>
{notice && <div className={`inline-notice ${notice.includes("不能") || notice.includes("失败") || notice.includes("唯一") ? "warn" : "ok"}`}><CheckCircle2 size={15} /><span>{notice}</span></div>}
<div className="admin-kpi-grid">
<AdminKpi icon={Users} label="全局用户" value={data.summary.total || 0} detail={`${data.summary.active || 0} 个正常`} tone="ok" />
<AdminKpi icon={Ban} label="已停用" value={data.summary.suspended || 0} detail="需要重新激活后登录" tone={data.summary.suspended ? "warn" : "neutral"} />
<AdminKpi icon={ShieldCheck} label="系统管理员" value={data.summary.systemAdmins || 0} detail="全局治理权限" tone="neutral" />
<AdminKpi icon={LogIn} label="活跃会话" value={data.summary.activeSessions || 0} detail="当前全平台" tone="ok" />
</div>
<section className="studio-card wide-card">
<SectionBar title="手动创建用户" detail="系统管理员可直接创建账号并指定初始归属" action={<button className="subtle" type="button" onClick={() => setCreateOpen((value) => !value)}><Plus size={15} />{createOpen ? "收起" : "创建账号"}</button>} />
{createOpen && <form className="commercial-plan-form system-user-create-form" onSubmit={createUser}>
<label>显示名称<input required value={createForm.displayName} onChange={(event) => setCreateForm({ ...createForm, displayName: event.target.value })} placeholder="例如:周制片" /></label>
<label>登录邮箱<input required type="email" value={createForm.email} onChange={(event) => setCreateForm({ ...createForm, email: event.target.value })} placeholder="name@studio.local" /></label>
<label>初始密码<input type="password" minLength="12" value={createForm.password} onChange={(event) => setCreateForm({ ...createForm, password: event.target.value })} placeholder="留空则生成一次性密码" /></label>
<label>组织归属<select value={createForm.organizationId} onChange={(event) => setCreateForm({ ...createForm, organizationId: event.target.value })}><option value="">稍后分配</option>{organizations.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>组织角色<select value={createForm.organizationRoleKey} onChange={(event) => setCreateForm({ ...createForm, organizationRoleKey: event.target.value })}><option value="org_member">组织成员</option><option value="org_admin">组织管理员</option><option value="org_owner">组织所有者</option></select></label>
<label>工作区<select value={createForm.workspaceId} onChange={(event) => setCreateForm({ ...createForm, workspaceId: event.target.value })}><option value="">稍后分配</option>{workspaces.map((item) => <option key={item.id} value={item.id}>{item.organization_name} / {item.name}</option>)}</select></label>
<label>工作区角色<select value={createForm.workspaceRoleKey} onChange={(event) => setCreateForm({ ...createForm, workspaceRoleKey: event.target.value })}><option value="writer">编剧</option><option value="producer">制片</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select></label>
<label className="checkbox-field"><input type="checkbox" checked={createForm.systemAdmin} onChange={(event) => setCreateForm({ ...createForm, systemAdmin: event.target.checked })} />授予系统管理员权限</label>
<div className="form-actions"><button className="primary" type="submit" disabled={busyId === "create"}><UserRoundCog size={15} />{busyId === "create" ? "创建中…" : "创建并写入审计"}</button></div>
</form>}
</section>
<section className="studio-card wide-card">
<SectionBar title="账号目录" detail={`${users.length} 条结果`} action={<form className="system-user-search" onSubmit={submitSearch}><div className="search-box"><Search size={15} /><input value={queryInput} onChange={(event) => setQueryInput(event.target.value)} placeholder="搜索姓名、邮箱或用户 ID" /></div><select value={status} onChange={(event) => { setStatus(event.target.value); load(query, event.target.value); }} aria-label="账号状态"><option value="">全部状态</option><option value="active">正常</option><option value="suspended">已停用</option><option value="invited">待激活</option></select><button className="primary" type="submit"><Search size={14} />查询</button></form>} />
{loading ? <div className="loading-state"><RefreshCw size={18} />读取全局用户目录…</div> : <div className="enterprise-table system-users-table"><div className="enterprise-table-head"><span>用户</span><span>组织归属</span><span>工作区 / 项目</span><span>身份安全</span><span>登录活动</span><span>状态</span><span>操作</span></div>{users.map((user) => <div className={`enterprise-table-row ${selectedId === user.id ? "selected-row" : ""}`} key={user.id} onClick={() => openDetail(user.id)}><div className="system-user-cell"><span className="system-user-avatar" style={{ background: user.avatarColor }}>{user.displayName?.slice(0, 1) || "U"}</span><div><strong>{user.displayName}</strong><span>{user.email}</span><small>{user.id}{user.systemAdmin ? " · 系统管理员" : ""}</small></div></div><div className="system-user-orgs">{user.organizations.slice(0, 2).map((organization) => <span key={organization.id}>{organization.name} · {organization.role_name || organization.role_key}</span>)}{user.organizations.length > 2 && <small>+{user.organizations.length - 2} 个组织</small>}{!user.organizations.length && <small>未加入组织</small>}</div><span>{user.workspaceCount} 工作区 · {user.projectCount} 项目</span><div className="system-user-security"><StatusBadge status={user.mfaEnabled ? "active" : "needs-evidence"} label={user.mfaEnabled ? "MFA 已启用" : "未启用 MFA"} /><small>{user.activeSessionCount} 个活跃会话</small></div><div className="system-user-activity"><strong>{formatAdminDate(user.lastLoginAt)}</strong><small>最近登录</small><small>{formatAdminDate(user.lastSessionSeenAt)}</small></div><StatusBadge status={user.status} label={systemUserStatusLabel(user.status)} /><div className="system-user-actions" onClick={(event) => event.stopPropagation()}><button className="icon-text-button" onClick={() => openDetail(user.id)}><ArrowUpRight size={13} />详情</button>{user.activeSessionCount > 0 && <button className="icon-text-button" onClick={() => revokeSessions(user)} disabled={busyId === user.id}><LogIn size={13} />撤销会话</button>}{user.status !== "invited" && <button className="icon-text-button" onClick={() => setUserStatus(user)} disabled={busyId === user.id}>{user.status === "suspended" ? <UserRoundCheck size={13} /> : <Ban size={13} />}{user.status === "suspended" ? "恢复" : "停用"}</button>}</div></div>)}{!users.length && <div className="empty-table">没有匹配的全局用户。</div>}</div>}
</section>
{selectedId && <section className="studio-card wide-card system-user-detail"><SectionBar title="用户详情" detail={detail?.user?.email || selectedId} action={<button className="icon-only-button" title="关闭用户详情" aria-label="关闭用户详情" onClick={() => { setSelectedId(""); setDetail(null); }}><XCircle size={17} /></button>} />{detailLoading || !detail ? <div className="loading-state"><RefreshCw size={18} />读取用户详情…</div> : <><div className="system-user-profile"><span className="system-user-avatar large" style={{ background: detail.user.avatarColor }}>{detail.user.displayName?.slice(0, 1) || "U"}</span><div><h3>{detail.user.displayName}</h3><p>{detail.user.email} · {detail.user.id}</p><span>创建于 {formatAdminDate(detail.user.createdAt)} · 最近登录 {formatAdminDate(detail.user.lastLoginAt)}</span></div><div className="system-user-profile-actions"><StatusBadge status={detail.user.status} label={systemUserStatusLabel(detail.user.status)} /><button className="subtle" onClick={() => resetPassword(detail.user)} disabled={busyId === detail.user.id}><KeyRound size={14} />重置密码</button><button className="subtle" onClick={() => resetMfa(detail.user)} disabled={busyId === detail.user.id}><ShieldAlert size={14} />重置 MFA</button><button className="subtle" onClick={() => toggleSystemAdmin(detail.user)} disabled={busyId === detail.user.id}><ShieldCheck size={14} />{detail.user.systemAdmin ? "移除系统管理员" : "授予系统管理员"}</button><button className="subtle" onClick={() => revokeSessions(detail.user)} disabled={busyId === detail.user.id}><LogIn size={14} />撤销全部会话</button>{detail.user.status !== "invited" && <button className={detail.user.status === "suspended" ? "primary" : "subtle"} onClick={() => setUserStatus(detail.user)} disabled={busyId === detail.user.id}>{detail.user.status === "suspended" ? <UserRoundCheck size={14} /> : <Ban size={14} />}{detail.user.status === "suspended" ? "恢复账号" : "停用账号"}</button>}</div></div><div className="system-membership-editor"><SectionBar title="调整组织 / 工作区 / 项目归属" detail="服务端会校验层级关系和席位" /><form className="commercial-plan-form" onSubmit={saveMemberships}><label>组织<select value={membershipForm.organizationId} onChange={(event) => setMembershipForm({ ...membershipForm, organizationId: event.target.value })}><option value="">不变</option>{organizations.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label><label>组织角色<select value={membershipForm.organizationRoleKey} onChange={(event) => setMembershipForm({ ...membershipForm, organizationRoleKey: event.target.value })}><option value="org_member">组织成员</option><option value="org_admin">组织管理员</option><option value="org_owner">组织所有者</option></select></label><label>工作区<select value={membershipForm.workspaceId} onChange={(event) => setMembershipForm({ ...membershipForm, workspaceId: event.target.value })}><option value="">不变</option>{workspaces.map((item) => <option key={item.id} value={item.id}>{item.organization_name} / {item.name}</option>)}</select></label><label>工作区角色<select value={membershipForm.workspaceRoleKey} onChange={(event) => setMembershipForm({ ...membershipForm, workspaceRoleKey: event.target.value })}><option value="writer">编剧</option><option value="producer">制片</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select></label><label>项目<select value={membershipForm.projectId} onChange={(event) => setMembershipForm({ ...membershipForm, projectId: event.target.value })}><option value="">不变</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.organization_name} / {item.name}</option>)}</select></label><label>项目角色<select value={membershipForm.projectRoleKey} onChange={(event) => setMembershipForm({ ...membershipForm, projectRoleKey: event.target.value })}><option value="project_editor">项目编辑</option><option value="project_viewer">项目查看者</option></select></label><div className="form-actions"><button className="subtle" type="submit" disabled={busyId === detail.user.id}><Save size={14} />保存归属</button></div></form></div><div className="system-user-detail-grid"><div><SectionBar title="组织与角色" detail={`${detail.user.organizations.length} 个组织`} />{detail.user.organizations.map((organization) => <div className="system-detail-row" key={organization.id}><strong>{organization.name}</strong><span>{organization.role_name || organization.role_key}</span><StatusBadge status={organization.status} label={organization.status === "active" ? "正常" : organization.status} /></div>)}{!detail.user.organizations.length && <div className="empty-table">暂无组织归属</div>}</div><div><SectionBar title="工作区与项目" detail={`${detail.user.workspaces.length} 个工作区 · ${detail.user.projects.length} 个项目`} />{detail.user.workspaces.slice(0, 8).map((workspace) => <div className="system-detail-row" key={workspace.id}><strong>{workspace.organization_name} / {workspace.name}</strong><span>{workspace.role_name || workspace.role_key}</span><StatusBadge status={workspace.status} label={workspace.status === "active" ? "正常" : workspace.status} /></div>)}{!detail.user.workspaces.length && <div className="empty-table">暂无工作区归属</div>}</div><div><SectionBar title="设备风险" detail={(detail.devices || []).length + " 台登记设备"} />{(detail.devices || []).slice(0, 8).map((device) => <div className="system-detail-row" key={device.id}><div><strong>{device.label || "浏览器设备"}</strong><span>{device.lastIpAddress || "未知 IP"} · 最近活动 {formatAdminDate(device.lastSeenAt)} · {device.activeSessionCount || 0} 个有效会话</span></div><StatusBadge status={riskBadgeStatus(device.latestRiskLevel)} label={"风险 " + riskLevelLabel(device.latestRiskLevel) + " · " + deviceStatusLabel(device.status)} /></div>)}{!(detail.devices || []).length && <div className="empty-table">暂无设备登记</div>}</div><div><SectionBar title="登录会话" detail={`${detail.sessions.filter((session) => session.status === "active" || session.status === "current").length} 个有效`} />{detail.sessions.slice(0, 8).map((session) => <div className="system-detail-row session-row" key={session.id}><div><strong>{session.current ? "当前查看会话" : session.device?.label || session.userAgent || "浏览器会话"}</strong><span>{session.ipAddress || "未知 IP"} · 最近活动 {formatAdminDate(session.lastSeenAt)} · 风险 {riskLevelLabel(session.riskLevel)}({session.riskScore})</span></div><StatusBadge status={session.status} label={(session.status === "current" ? "当前" : session.status === "active" ? "有效" : session.status === "revoked" ? "已撤销" : "已过期") + " · 风险 " + riskLevelLabel(session.riskLevel)} /></div>)}{!detail.sessions.length && <div className="empty-table">暂无登录会话</div>}</div><div><SectionBar title="账号安全事件" detail={`${(detail.securityEvents || []).length} 条最近事件`} /><div className="compact-audit-list system-security-event-list">{(detail.securityEvents || []).slice(0, 8).map((event) => <div className="system-detail-row" key={event.id}><div><strong>{securityEventLabel(event.eventType)}</strong><span>{formatAdminDate(event.createdAt)} · {securityEventSummary(event)}</span></div><StatusBadge status={securityResultTone(event.result)} label={securityResultLabel(event.result)} /></div>)}{!(detail.securityEvents || []).length && <div className="empty-table">暂无账号安全事件</div>}</div></div><div><SectionBar title="治理审计" detail={`${detail.recentAudit.length} 条相关事件`} />{detail.recentAudit.slice(0, 8).map((audit) => <div className="system-detail-row" key={audit.id}><div><strong>{audit.action}</strong><span>{formatAdminDate(audit.created_at)} · {audit.actor_name || "system"}</span></div><StatusBadge status={audit.result} label={audit.result === "ok" ? "成功" : audit.result} /></div>)}{!detail.recentAudit.length && <div className="empty-table">暂无相关事件</div>}</div></div></>}</section>}
</div>
);
}
export function IdentityCenterPage({ contextOverrides }) {
const [data, setData] = useState({
policy: { passwordLoginEnabled: true, mfaRequiredForAdmins: false, mfaRequiredForAll: false, ssoEnabled: false, localLoginFallback: true, sessionTtlHours: 12, maxSessionsPerUser: 10 },
providers: [],
directorySyncs: [],
organizationOptions: [],
workspaceOptions: [],
summary: {}
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const [providerForm, setProviderForm] = useState({ name: "企业 OIDC", kind: "oidc", organizationId: contextOverrides.organizationId || "", workspaceId: contextOverrides.workspaceId || "", issuerUrl: "", clientId: "", clientSecretRef: "OIDC_CLIENT_SECRET", scopes: "openid, profile, email", entryPoint: "", idpCertRef: "AI_DRAMA_SAML_IDP_CERT", spIssuer: "http://127.0.0.1:8787/api/auth/sso/saml/metadata", audience: "", samlNameIdFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", wantAssertionsSigned: true, wantAuthnResponseSigned: true, validateInResponseTo: "always", claimMapping: { email: "email", displayName: "name", externalId: "sub" }, autoProvision: true, defaultRoleKey: "org_member", defaultWorkspaceRoleKey: "writer", enabled: false });
const [directoryForm, setDirectoryForm] = useState({ name: "企业目录", syncMode: "provision-and-deprovision", schedule: "manual" });
const [tokenNotice, setTokenNotice] = useState("");
async function load() {
setLoading(true);
try {
setData(await fetchIdentityCenter(contextOverrides));
setNotice("");
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [contextOverrides.organizationId]);
function setPolicy(key, value) {
setData((current) => ({ ...current, policy: { ...current.policy, [key]: value } }));
}
async function savePolicy() {
setSaving(true);
try {
const result = await updateIdentityPolicy(data.policy, contextOverrides);
setData(result);
setNotice("身份策略已保存,并已写入审计日志");
} catch (error) {
setNotice(error.message);
} finally {
setSaving(false);
}
}
async function submitProvider(event) {
event.preventDefault();
try {
const result = await createIdentityProvider({ ...providerForm, scopes: providerForm.scopes.split(",").map((item) => item.trim()).filter(Boolean) }, contextOverrides);
setData(result);
setNotice("身份提供商已登记;启用前请先完成探测");
setProviderForm((current) => ({ ...current, name: current.kind === "saml" ? "企业 SAML" : "企业 OIDC", issuerUrl: "", clientId: "", entryPoint: "", enabled: false }));
} catch (error) {
setNotice(error.message);
}
}
async function toggleProvider(provider) {
try {
const result = await updateIdentityProvider(provider.id, { enabled: !provider.enabled }, contextOverrides);
setData(result);
setNotice(`身份提供商“${provider.name}”已${provider.enabled ? "停用" : "启用"}`);
} catch (error) {
setNotice(error.message);
}
}
async function probeProvider(provider) {
try {
const result = await probeIdentityProvider(provider.id, contextOverrides);
setData((current) => ({ ...current, providers: current.providers.map((item) => item.id === result.provider.id ? result.provider : item), summary: { ...current.summary, readyProviders: current.providers.filter((item) => item.id === result.provider.id ? result.provider.status === "ready" : item.status === "ready").length } }));
setNotice(`“${provider.name}”探测完成:${result.provider.status === "ready" ? "就绪" : result.provider.status}`);
} catch (error) {
setNotice(error.message);
await load();
}
}
async function submitDirectory(event) {
event.preventDefault();
try {
const result = await createDirectorySync(directoryForm, contextOverrides);
setData((current) => ({ ...current, directorySyncs: [...current.directorySyncs, result.directorySync], summary: { ...current.summary, managedOrganizations: new Set([...current.directorySyncs, result.directorySync].map((item) => item.organizationId).filter(Boolean)).size } }));
setTokenNotice(`SCIM 令牌只显示这一次:${result.token}`);
setDirectoryForm({ name: "企业目录", syncMode: "provision-and-deprovision", schedule: "manual" });
} catch (error) {
setNotice(error.message);
}
}
async function toggleDirectory(directory) {
try {
const result = await updateDirectorySync(directory.id, { enabled: !directory.enabled }, contextOverrides);
setData(result);
setNotice(`目录同步“${directory.name}”已${directory.enabled ? "停用" : "启用"}`);
} catch (error) {
setNotice(error.message);
}
}
async function rotateToken(directory) {
try {
const result = await rotateDirectorySyncToken(directory.id, contextOverrides);
setData((current) => ({ ...current, directorySyncs: current.directorySyncs.map((item) => item.id === directory.id ? result.directorySync : item) }));
setTokenNotice(`SCIM 令牌已轮换,只显示这一次:${result.token}`);
} catch (error) {
setNotice(error.message);
}
}
return (
<div className="enterprise-page">
<AdminHeader eyebrow="SYSTEM SETTINGS / IDENTITY CENTER" title="企业身份与目录" description="统一管理本地登录、MFA、OIDC/SAML 身份提供商和 SCIM 目录同步。所有策略变更都受系统权限控制并写入审计日志。" action={<div className="header-actions"><button className="subtle" onClick={load}><RefreshCw size={15} />刷新</button><button className="primary" onClick={savePolicy} disabled={saving || loading}><Save size={15} />{saving ? "保存中…" : "保存身份策略"}</button></div>} />
{notice && <div className={`inline-notice ${notice.includes("失败") || notice.includes("不能") || notice.includes("缺少") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
{tokenNotice && <div className="inline-notice warn"><KeyRound size={15} /><span>{tokenNotice}</span><button className="icon-only-button" title="关闭一次性令牌提示" aria-label="关闭一次性令牌提示" onClick={() => setTokenNotice("")}><XCircle size={15} /></button></div>}
{loading ? <div className="loading-state"><RefreshCw size={18} />读取身份配置…</div> : <>
<div className="admin-kpi-grid">
<AdminKpi icon={ShieldCheck} label="身份提供商" value={`${data.summary.readyProviders || 0}/${data.summary.enabledProviders || 0}`} detail="就绪 / 已启用" tone={data.summary.readyProviders ? "ok" : "neutral"} />
<AdminKpi icon={Users} label="目录同步" value={data.summary.enabledDirectorySyncs || 0} detail="启用中的 SCIM 目录" tone={data.summary.enabledDirectorySyncs ? "ok" : "neutral"} />
<AdminKpi icon={KeyRound} label="MFA 策略" value={data.policy.mfaRequiredForAll ? "全员" : data.policy.mfaRequiredForAdmins ? "管理员" : "按账号"} detail="登录二次验证范围" tone={data.policy.mfaRequiredForAll || data.policy.mfaRequiredForAdmins ? "ok" : "neutral"} />
<AdminKpi icon={Network} label="登录模式" value={data.policy.ssoEnabled ? "SSO" : "本地"} detail={data.policy.localLoginFallback ? "保留本地回退" : "仅企业身份"} tone={data.policy.ssoEnabled ? "ok" : "neutral"} />
</div>
<section className="studio-card wide-card identity-policy-card">
<SectionBar title="登录与会话策略" detail="系统范围" />
<div className="identity-policy-grid">
<div><strong>密码登录</strong><span>允许本地账号使用邮箱和密码登录。</span><button type="button" className={`switch-control ${data.policy.passwordLoginEnabled ? "on" : ""}`} onClick={() => setPolicy("passwordLoginEnabled", !data.policy.passwordLoginEnabled)}><span />{data.policy.passwordLoginEnabled ? "启用" : "关闭"}</button></div>
<div><strong>企业 SSO</strong><span>启用已探测通过的 OIDC/SAML 提供商。</span><button type="button" className={`switch-control ${data.policy.ssoEnabled ? "on" : ""}`} onClick={() => setPolicy("ssoEnabled", !data.policy.ssoEnabled)}><span />{data.policy.ssoEnabled ? "启用" : "关闭"}</button></div>
<div><strong>本地登录回退</strong><span>SSO 故障时保留管理员控制入口。</span><button type="button" className={`switch-control ${data.policy.localLoginFallback ? "on" : ""}`} onClick={() => setPolicy("localLoginFallback", !data.policy.localLoginFallback)}><span />{data.policy.localLoginFallback ? "保留" : "关闭"}</button></div>
<div><strong>管理员强制 MFA</strong><span>系统管理员和组织管理员必须启用 MFA。</span><button type="button" className={`switch-control ${data.policy.mfaRequiredForAdmins ? "on" : ""}`} onClick={() => setPolicy("mfaRequiredForAdmins", !data.policy.mfaRequiredForAdmins)}><span />{data.policy.mfaRequiredForAdmins ? "强制" : "建议"}</button></div>
<div><strong>全员强制 MFA</strong><span>所有活跃账号登录前必须完成 MFA。</span><button type="button" className={`switch-control ${data.policy.mfaRequiredForAll ? "on" : ""}`} onClick={() => setPolicy("mfaRequiredForAll", !data.policy.mfaRequiredForAll)}><span />{data.policy.mfaRequiredForAll ? "强制" : "关闭"}</button></div>
<label><strong>会话时长(小时)</strong><span>浏览器会话最长有效期。</span><input type="number" min="1" max="168" value={data.policy.sessionTtlHours} onChange={(event) => setPolicy("sessionTtlHours", Number(event.target.value))} /></label>
<label><strong>单用户最大会话数</strong><span>超过上限后优先淘汰最久未使用会话。</span><input type="number" min="1" max="50" value={data.policy.maxSessionsPerUser} onChange={(event) => setPolicy("maxSessionsPerUser", Number(event.target.value))} /></label>
</div>
</section>
<div className="enterprise-grid enterprise-grid-main">
<section className="studio-card">
<SectionBar title="身份提供商" detail={`${data.providers.length} 个登记配置`} />
<div className="identity-provider-list">{data.providers.map((provider) => <div className="identity-provider-row" key={provider.id}><div className="identity-provider-icon"><Network size={17} /></div><div><strong>{provider.name}</strong><span>{provider.kind.toUpperCase()} · {provider.issuerUrl || (provider.kind === "saml" ? provider.entryPoint || "尚未填写 Entry Point" : "尚未填写 Issuer")}</span><small>{provider.kind === "saml" ? `SP ${provider.spIssuer || "未填写"} · 证书 ${provider.idpCertRef || "未绑定"}` : provider.clientId ? `Client ID ${provider.clientId}` : "缺少 Client ID"}{provider.organizationId ? ` · 组织 ${data.organizationOptions.find((item) => item.id === provider.organizationId)?.name || provider.organizationId}` : " · 未绑定组织"}{provider.workspaceId ? ` · 工作区 ${data.workspaceOptions.find((item) => item.id === provider.workspaceId)?.name || provider.workspaceId}` : ""}{provider.errorMessage ? ` · ${provider.errorMessage}` : ""}</small></div><StatusBadge status={provider.status} label={provider.status === "ready" ? "就绪" : provider.status === "configured" ? "已配置" : provider.status === "error" ? "探测失败" : provider.enabled ? "待配置" : "已停用"} /><button className="icon-text-button" onClick={() => probeProvider(provider)}><RefreshCw size={13} />探测</button><button className={`switch-control small ${provider.enabled ? "on" : ""}`} onClick={() => toggleProvider(provider)}><span />{provider.enabled ? "启用" : "停用"}</button></div>)}</div>
{!data.providers.length && <div className="empty-table">还没有身份提供商。先登记一个 OIDC 或 SAML 配置,再进行探测。</div>}
<form className="identity-form" onSubmit={submitProvider}>
<div className="subsection-heading"><strong>登记提供商</strong><span>密钥只填写环境变量名</span></div>
<div className="form-grid-two">
<label>名称<input value={providerForm.name} onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })} required /></label>
<label>协议<select value={providerForm.kind} onChange={(event) => setProviderForm({ ...providerForm, kind: event.target.value, name: event.target.value === "saml" ? "企业 SAML" : "企业 OIDC" })}><option value="oidc">OIDC</option><option value="saml">SAML</option></select></label>
<label>归属组织<select value={providerForm.organizationId} onChange={(event) => setProviderForm({ ...providerForm, organizationId: event.target.value, workspaceId: "" })}><option value="">选择组织</option>{data.organizationOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>默认工作区<select value={providerForm.workspaceId} onChange={(event) => setProviderForm({ ...providerForm, workspaceId: event.target.value })}><option value="">自动选择首个工作区</option>{data.workspaceOptions.filter((item) => !providerForm.organizationId || item.organizationId === providerForm.organizationId).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label className="span-two">IdP Issuer URL(可选)<input type="url" placeholder="https://id.example.com/realms/studio" value={providerForm.issuerUrl} onChange={(event) => setProviderForm({ ...providerForm, issuerUrl: event.target.value })} /></label>
{providerForm.kind === "oidc" ? <>
<label>Client ID<input value={providerForm.clientId} onChange={(event) => setProviderForm({ ...providerForm, clientId: event.target.value })} /></label>
<label>密钥环境变量名<input value={providerForm.clientSecretRef} onChange={(event) => setProviderForm({ ...providerForm, clientSecretRef: event.target.value })} /></label>
<label className="span-two">Scopes<input value={providerForm.scopes} onChange={(event) => setProviderForm({ ...providerForm, scopes: event.target.value })} /></label>
</> : <>
<label className="span-two">SAML IdP Entry Point<input type="url" placeholder="https://id.example.com/sso" value={providerForm.entryPoint} onChange={(event) => setProviderForm({ ...providerForm, entryPoint: event.target.value })} /></label>
<label>IdP 证书环境变量名<input placeholder="AI_DRAMA_SAML_IDP_CERT" value={providerForm.idpCertRef} onChange={(event) => setProviderForm({ ...providerForm, idpCertRef: event.target.value })} /></label>
<label>SP Issuer / Entity ID<input placeholder="https://studio.example.com/saml" value={providerForm.spIssuer} onChange={(event) => setProviderForm({ ...providerForm, spIssuer: event.target.value })} required /></label>
<label>Audience(留空跟随 SP Issuer)<input placeholder="https://studio.example.com/saml" value={providerForm.audience} onChange={(event) => setProviderForm({ ...providerForm, audience: event.target.value })} /></label>
<label>NameID Format<input value={providerForm.samlNameIdFormat} onChange={(event) => setProviderForm({ ...providerForm, samlNameIdFormat: event.target.value })} /></label>
<label>InResponseTo 校验<select value={providerForm.validateInResponseTo} onChange={(event) => setProviderForm({ ...providerForm, validateInResponseTo: event.target.value })}><option value="always">always(推荐)</option><option value="ifPresent">ifPresent</option><option value="never">never(不推荐)</option></select></label>
<label className="checkbox-field"><input type="checkbox" checked={providerForm.wantAssertionsSigned} onChange={(event) => setProviderForm({ ...providerForm, wantAssertionsSigned: event.target.checked })} />要求 Assertion 签名</label>
<label className="checkbox-field"><input type="checkbox" checked={providerForm.wantAuthnResponseSigned} onChange={(event) => setProviderForm({ ...providerForm, wantAuthnResponseSigned: event.target.checked })} />要求 Response 签名</label>
<label>SAML 邮箱属性<input value={providerForm.claimMapping.email} onChange={(event) => setProviderForm({ ...providerForm, claimMapping: { ...providerForm.claimMapping, email: event.target.value } })} /></label>
<label>SAML 姓名属性<input value={providerForm.claimMapping.displayName} onChange={(event) => setProviderForm({ ...providerForm, claimMapping: { ...providerForm.claimMapping, displayName: event.target.value } })} /></label>
<label>SAML 外部身份属性<input value={providerForm.claimMapping.externalId} onChange={(event) => setProviderForm({ ...providerForm, claimMapping: { ...providerForm.claimMapping, externalId: event.target.value } })} /></label>
</>}
<label>组织默认角色<select value={providerForm.defaultRoleKey} onChange={(event) => setProviderForm({ ...providerForm, defaultRoleKey: event.target.value })}><option value="org_member">组织成员</option><option value="org_admin">组织管理员</option></select></label>
<label>工作区默认角色<select value={providerForm.defaultWorkspaceRoleKey} onChange={(event) => setProviderForm({ ...providerForm, defaultWorkspaceRoleKey: event.target.value })}><option value="writer">编剧</option><option value="producer">制片</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select></label>
<label className="checkbox-field"><input type="checkbox" checked={providerForm.autoProvision} onChange={(event) => setProviderForm({ ...providerForm, autoProvision: event.target.checked })} />首次 SSO 自动创建并入组</label>
</div>
<button className="subtle full-width" type="submit"><Plus size={15} />登记身份提供商</button>
</form>
</section>
<section className="studio-card">
<SectionBar title="SCIM 企业目录" detail={`${data.directorySyncs.length} 个目录`} />
<div className="identity-directory-list">{data.directorySyncs.map((directory) => <div className="identity-directory-row" key={directory.id}><div className="identity-provider-icon"><Users size={17} /></div><div><strong>{directory.name}</strong><span>{directory.organizationName || directory.organizationId} · {directory.syncMode}</span><small>{directory.endpointPath} · {directory.tokenHint}</small></div><StatusBadge status={directory.enabled ? "active" : "disabled"} label={directory.enabled ? "启用" : "停用"} /><button className="icon-text-button" onClick={() => rotateToken(directory)}><KeyRound size={13} />轮换令牌</button><button className={`switch-control small ${directory.enabled ? "on" : ""}`} onClick={() => toggleDirectory(directory)}><span />{directory.enabled ? "启用" : "停用"}</button></div>)}</div>
{!data.directorySyncs.length && <div className="empty-table">还没有 SCIM 目录。创建后可接收企业目录的用户新增、更新和停用事件。</div>}
<form className="identity-form" onSubmit={submitDirectory}><div className="subsection-heading"><strong>创建目录连接</strong><span>令牌只在创建/轮换时显示</span></div><div className="form-grid-two"><label className="span-two">目录名称<input value={directoryForm.name} onChange={(event) => setDirectoryForm({ ...directoryForm, name: event.target.value })} required /></label><label>同步策略<select value={directoryForm.syncMode} onChange={(event) => setDirectoryForm({ ...directoryForm, syncMode: event.target.value })}><option value="provision-and-deprovision">新增 + 更新 + 停用</option><option value="provision-only">仅新增和更新</option></select></label><label>运行方式<select value={directoryForm.schedule} onChange={(event) => setDirectoryForm({ ...directoryForm, schedule: event.target.value })}><option value="manual">手动 / Webhook</option><option value="hourly">每小时</option><option value="daily">每天</option></select></label></div><button className="subtle full-width" type="submit"><Plus size={15} />创建 SCIM 目录</button></form>
</section>
</div>
</>}
</div>
);
}
const settingLabels = { general: "基础设置", deployment: "部署与安全", storage: "存储", queue: "队列", generation: "生成策略", voice: "声音策略", qa: "质量门", notifications: "通知", api: "API" };
const fallbackApiClientScopes = [
{ key: "jobs:read", label: "读取任务", description: "查看当前项目的生成任务和详情" },
{ key: "jobs:write", label: "写入任务", description: "创建、执行、重试、取消和调整任务" },
{ key: "models:read", label: "读取模型", description: "查看模型连接器和 Runner" },
{ key: "models:write", label: "管理模型", description: "登记、修改和探测模型连接器" },
{ key: "audit:read", label: "读取审计", description: "查看和导出审计记录" }
];
function SettingInput({ setting, value, onChange }) {
if (setting.value_type === "boolean") return <button type="button" className={`switch-control ${value ? "on" : ""}`} onClick={() => onChange(!value)} aria-pressed={value}><span />{value ? "已启用" : "已关闭"}</button>;
return <input type={setting.value_type === "number" ? "number" : "text"} value={value ?? ""} onChange={(event) => onChange(setting.value_type === "number" ? Number(event.target.value) : event.target.value)} />;
}
function readinessBadge(check) {
const map = {
ready: { status: "ready", label: "正常" },
configured: { status: "active", label: "已配置" },
"active-local": { status: "needs-evidence", label: "当前本地" },
"not-configured": { status: "needs-evidence", label: "未配置" },
"needs-config": { status: "failed", label: "待配置" },
unsafe: { status: "failed", label: "高风险" },
missing: { status: "needs-evidence", label: "未创建" },
failed: { status: "failed", label: "失败" }
};
return map[check.status] || { status: "neutral", label: check.status || "未知" };
}
export function SystemSettingsPage({ section = "overview", contextOverrides }) {
const [config, setConfig] = useState({ settings: [], featureFlags: [], notifications: [], apiClients: [], apiClientScopes: fallbackApiClientScopes });
const [health, setHealth] = useState({ services: [], summary: {} });
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
const [storage, setStorage] = useState(null);
const [deliveries, setDeliveries] = useState([]);
const [originalSettings, setOriginalSettings] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const [clientName, setClientName] = useState("");
const [apiKeyNotice, setApiKeyNotice] = useState("");
const [backupBusy, setBackupBusy] = 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)]);
setConfig(nextConfig);
const values = Object.fromEntries(nextConfig.settings.map((setting) => [setting.key, setting.value]));
setOriginalSettings(values);
setHealth(nextHealth);
setReadiness(nextReadiness);
setStorage(nextStorage.storage || null);
setDeliveries(nextDeliveries.deliveries || []);
setNotice("");
} catch (error) {
setNotice(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [contextOverrides.organizationId]);
const visibleCategories = section === "generation" ? ["generation", "voice", "qa"] : section === "storage" ? ["storage", "queue"] : section === "notifications" ? ["notifications", "api"] : section === "deployment" ? ["deployment", "general"] : section === "features" ? [] : null;
const visibleSettings = config.settings.filter((setting) => !visibleCategories || visibleCategories.includes(setting.category));
const dirtySettings = config.settings.filter((setting) => JSON.stringify(originalSettings[setting.key]) !== JSON.stringify(setting.value));
const title = section === "overview" ? "系统总览" : section === "generation" ? "生成策略" : section === "storage" ? "存储与队列" : section === "notifications" ? "通知与 API" : section === "features" ? "功能开关" : "部署与安全";
const description = section === "overview" ? "部署实例、服务健康、功能开关和关键策略的系统级控制面。" : "系统管理员配置会影响所有组织和工作区,变更会写入审计日志。";
function setSetting(key, value) { setConfig((current) => ({ ...current, settings: current.settings.map((setting) => setting.key === key ? { ...setting, value } : setting) })); }
async function save() {
if (!dirtySettings.length) { setNotice("当前没有待保存的系统配置"); return; }
setSaving(true);
try {
const result = await saveSystemConfig(dirtySettings.map((setting) => ({ key: setting.key, value: setting.value })), contextOverrides);
setConfig((current) => ({ ...current, settings: result.settings }));
setOriginalSettings(Object.fromEntries(result.settings.map((setting) => [setting.key, setting.value])));
setNotice(`已保存 ${dirtySettings.length} 项系统配置,并写入审计日志`);
} catch (error) { setNotice(error.message); } finally { setSaving(false); }
}
async function toggleFlag(flag) {
try { const result = await updateFeatureFlag(flag.key, !flag.enabled, contextOverrides); setConfig((current) => ({ ...current, featureFlags: result.featureFlags })); setNotice(`功能开关“${flag.label}”已更新`); } catch (error) { setNotice(error.message); }
}
async function toggleNotification(channel) {
try { const result = await updateNotificationChannel({ ...channel, enabled: !channel.enabled }, contextOverrides); setConfig((current) => ({ ...current, notifications: result.notifications })); setNotice(`通知渠道“${channel.name}”已更新`); } catch (error) { setNotice(error.message); }
}
async function sendNotificationTest() {
try {
const result = await testNotification({ event: "system.changed", payload: { source: "system-settings", testedAt: new Date().toISOString() } }, contextOverrides);
setDeliveries(result.deliveries || []);
setNotice(`通知测试完成,新增 ${result.created?.length || 0} 条投递记录`);
} catch (error) { setNotice(error.message); }
}
async function createClient(event) {
event.preventDefault();
try { const result = await createApiClient({ name: clientName || "本地 API 客户端", scopes: ["jobs:read", "jobs:write", "models:read"] }, contextOverrides); setConfig((current) => ({ ...current, apiClients: result.apiClients || [] })); setApiKeyNotice(`客户端已创建。请立即保存一次性密钥:${result.clientKey}`); setClientName(""); } catch (error) { setApiKeyNotice(error.message); }
}
async function toggleApiClient(client) {
try {
const nextStatus = client.status === "active" ? "revoked" : "active";
const result = await updateApiClient(client.id, { status: nextStatus }, contextOverrides);
setConfig((current) => ({ ...current, apiClients: result.apiClients || current.apiClients }));
setApiKeyNotice(`API 客户端“${client.name}”已${nextStatus === "active" ? "恢复" : "撤销"}`);
} catch (error) {
setApiKeyNotice(error.message);
}
}
async function toggleApiClientScope(client, scopeKey) {
const nextScopes = client.scopes.includes(scopeKey) ? client.scopes.filter((scope) => scope !== scopeKey) : [...client.scopes, scopeKey];
if (!nextScopes.length) {
setApiKeyNotice("API 客户端至少需要保留一个 scope");
return;
}
try {
const result = await updateApiClient(client.id, { scopes: nextScopes }, contextOverrides);
setConfig((current) => ({ ...current, apiClients: result.apiClients || current.apiClients }));
setApiKeyNotice(`API 客户端“${client.name}”的权限范围已更新`);
} catch (error) {
setApiKeyNotice(error.message);
}
}
async function rotateClientKey(client) {
if (typeof window !== "undefined" && !window.confirm(`确认轮换“${client.name}”的 API 密钥吗?旧密钥会立即失效。`)) return;
try {
const result = await rotateApiClientKey(client.id, contextOverrides);
setConfig((current) => ({ ...current, apiClients: result.apiClients || current.apiClients }));
setApiKeyNotice(`“${client.name}”的新密钥只显示这一次,请立即保存:${result.clientKey}`);
} catch (error) {
setApiKeyNotice(error.message);
}
}
async function createBackup() {
setBackupBusy(true);
try {
const result = await createSystemBackup(contextOverrides);
setReadiness((current) => ({ ...current, backups: { ...(current.backups || {}), count: result.count, latest: result.latest, backups: result.backups } }));
setNotice(`数据库快照已创建:${result.backup.relativePath}`);
} catch (error) {
setNotice(error.message);
} finally {
setBackupBusy(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>} />
{notice && <div className={`inline-notice ${notice.includes("失败") || notice.includes("不存在") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
{loading ? <div className="loading-state"><RefreshCw size={18} />读取系统配置…</div> : <>
{section === "overview" && <div className="admin-kpi-grid"><AdminKpi icon={ServerCog} label="服务总数" value={health.summary.total || health.services.length} detail={`${health.summary.ready || 0} 个正常`} tone="ok" /><AdminKpi icon={AlertTriangle} label="需要关注" value={health.summary.attention || 0} detail="Runner 或模型接入状态" tone={health.summary.attention ? "warn" : "ok"} /><AdminKpi icon={Settings2} label="系统配置" value={config.settings.length} detail="已持久化配置项" tone="neutral" /><AdminKpi icon={Zap} label="功能开关" value={`${config.featureFlags.filter((flag) => flag.enabled).length}/${config.featureFlags.length}`} detail="当前启用" tone="ok" /></div>}
{(section === "overview" || section === "deployment") && <section className="studio-card wide-card"><SectionBar title="服务健康" detail={`最近检查 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`} /><div className="health-grid">{health.services.map((service) => <div key={service.id}><div><span className={`service-dot ${service.status === "ready" ? "ok" : "warn"}`} /><strong>{service.label}</strong></div><span>{service.endpoint}</span><div><StatusBadge status={service.status} label={service.status === "ready" ? "正常" : service.status} /><small>{service.latency_ms ? `${service.latency_ms}ms` : "未上报延迟"}</small></div></div>)}</div></section>}
{(section === "overview" || section === "deployment") && <section className="studio-card wide-card"><SectionBar title="生产就绪度" detail={`${readiness.profile || "local-development"} · ${readiness.summary?.attention || 0} 项需要关注`} action={<StatusBadge status={readiness.summary?.status === "ready" ? "ready" : readiness.summary?.status === "blocked" ? "failed" : "needs-evidence"} label={readiness.summary?.status === "ready" ? "可运行" : readiness.summary?.status === "blocked" ? "有阻断项" : "需处理"} />} /><div className="readiness-grid">{(readiness.checks || []).map((check) => { const badge = readinessBadge(check); return <div key={check.key}><div><strong>{check.label}</strong><StatusBadge status={badge.status} label={badge.label} /></div><span>{check.detail}</span>{check.envName && <small>{check.envName}</small>}</div>; })}</div></section>}
{(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 === "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>}
</>}
</div>
);
}