2539 lines
218 KiB
React
2539 lines
218 KiB
React
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>
|
||
);
|
||
}
|