Files
ai-drama-platform/src/App.jsx
T
2026-09-01 13:22:04 +08:00

1955 lines
106 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
Archive,
AudioLines,
Bell,
Bot,
BookOpen,
Building2,
CheckCircle2,
Clapperboard,
Coins,
Copy,
ChevronRight,
Download,
FileJson2,
Film,
FolderKanban,
Gauge,
HardDrive,
Images,
KeyRound,
Layers3,
Link2,
ListChecks,
ListPlus,
LogOut,
Mail,
Menu,
Mic2,
Network,
Palette,
Plus,
Play,
RadioTower,
RefreshCw,
Save,
Scale,
Search,
Settings2,
ShieldCheck,
Sparkles,
TimerReset,
UserPlus,
Users,
Volume2,
Wand2,
XCircle
} from "lucide-react";
import { adapters, marketBenchmarks, workflowTemplates } from "./data/sampleProject";
import { createProjectShell, emptyProject } from "./data/projectShell";
import { ScenePreview } from "./components/ScenePreview";
import {
AdminAuditPage,
AdminModelsPage,
AdminOverviewPage,
AdminPolicyCenterPage,
AdminStyleKitsPage,
AdminQueuePage,
AdminUsagePage,
AccountSecurityPage,
CreatorHomePage,
IdentityCenterPage,
LoginPage,
NotificationBell,
NotificationCenterPage,
TaskCenterPage,
RegisterPage,
SystemSettingsPage,
SystemUsersPage
} from "./components/EnterprisePages";
import { DeliveryPortalPage } from "./components/DeliveryPortalPage";
import {
AssetLibraryPage,
BatchProductionPage,
ProjectAssistantPage,
VoiceStudioPage
} from "./components/CreatorSuitePages";
import { KnowledgeLibraryPage } from "./components/KnowledgeLibraryPage";
import {
CastingBoardPage,
DeliveryAccessPage,
DeliveryOpsPage,
DirectorProductionPage,
GenerationQueuePage,
ProjectFactoryPage,
QaReviewPage,
ScriptProductionPage,
SeriesBiblePage
} from "./components/ProductionPages";
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, searchPlatform, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters";
import { projectQa } from "./lib/qa";
import { getPlatformSummary, platformData } from "./platform/platformData";
const tabs = [
{ id: "overview", label: "生产总控", icon: RadioTower },
{ id: "factory", label: "项目工厂", icon: Layers3 },
{ id: "script", label: "剧本拆解", icon: BookOpen },
{ id: "casting", label: "资产选角", icon: ShieldCheck },
{ id: "director", label: "导演工作台", icon: Clapperboard },
{ id: "jobs", label: "生成队列", icon: Wand2 },
{ id: "modelops", label: "模型中台", icon: Network },
{ id: "qa", label: "审片中心", icon: ListChecks },
{ id: "compliance", label: "成本合规", icon: Scale },
{ id: "export", label: "交付运营", icon: Download },
{ id: "admin", label: "平台管理", icon: KeyRound }
];
const navigationGroups = [
{
label: "创作套件",
items: [
{ id: "creator-home", label: "我的工作台", icon: RadioTower },
{ id: "tasks", label: "协作任务", icon: ListChecks, requiredPermission: "task:view" },
{ id: "assistant", label: "项目 AI 助手", icon: Bot },
{ id: "knowledge", label: "内容知识库", icon: FileJson2, requiredPermission: "script:read" },
{ id: "asset-library", label: "资产库", icon: Images, requiredAnyPermissions: ["asset:edit", "voice:edit", "compliance:manage"] },
{ id: "voice-studio", label: "声音与字幕", icon: AudioLines, requiredPermission: "voice:edit" },
{ id: "batch-production", label: "批量生产", icon: ListPlus, requiredPermission: "job:create" }
]
},
{
label: "账户",
items: [
{ id: "account", label: "账号与安全", icon: KeyRound },
{ id: "notifications", label: "通知中心", icon: Bell }
]
},
{
label: "生产流水线",
items: [
{ id: "overview", label: "生产总控", icon: RadioTower, requiredPermission: "usage:view" },
{ id: "factory", label: "项目工厂", icon: Layers3, requiredPermission: "project:create" },
{ id: "script", label: "剧本拆解", icon: BookOpen, requiredPermission: "script:edit" },
{ id: "casting", label: "资产与选角", icon: ShieldCheck, requiredAnyPermissions: ["asset:edit", "compliance:manage"] },
{ id: "director", label: "导演工作台", icon: Clapperboard, requiredPermission: "job:create" },
{ id: "jobs", label: "生成任务", icon: Wand2, requiredPermission: "job:create" },
{ id: "bible", label: "系列 Bible", icon: BookOpen, requiredPermission: "script:edit" },
{ id: "locks", label: "连续性锁", icon: ShieldCheck, requiredAnyPermissions: ["asset:edit", "compliance:manage"] },
{ id: "shots", label: "镜头清单", icon: Clapperboard, requiredPermission: "job:create" },
{ id: "modelops", label: "模型中台", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
{ id: "qa", label: "审片中心", icon: ListChecks, requiredPermission: "qa:review" },
{ id: "compliance", label: "成本合规", icon: Scale, requiredPermission: "usage:view" },
{ id: "export", label: "交付运营", icon: Download, requiredPermission: "delivery:view" },
{ id: "delivery-portal", label: "客户交付门户", icon: Link2, requiredPermission: "delivery:view" }
]
},
{
label: "管理员后台",
items: [
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
{ id: "admin-policies", label: "组织策略", icon: ShieldCheck, requiredAnyPermissions: ["policy:read", "policy:manage", "organization:manage"] },
{ id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
{ id: "admin-audit", label: "审计与合规", icon: ListChecks, requiredPermission: "audit:view" }
]
},
{
label: "系统设置",
items: [
{ id: "system-overview", label: "系统总览", icon: Settings2, requiredPermission: "system:settings:view" },
{ id: "system-identity", label: "企业身份", icon: KeyRound, requiredPermission: "system:settings:view" },
{ id: "system-users", label: "全局用户目录", icon: Users, requiredPermission: "system:settings:view" },
{ id: "system-generation", label: "生成策略", icon: ShieldCheck, requiredPermission: "system:settings:view" },
{ id: "system-storage", label: "存储与队列", icon: HardDrive, requiredPermission: "system:settings:view" },
{ id: "system-notifications", label: "通知与 API", icon: Mail, requiredPermission: "system:settings:view" },
{ id: "system-features", label: "功能开关", icon: Sparkles, requiredPermission: "feature_flag:manage" }
]
}
];
const navigationTabIds = new Set(navigationGroups.flatMap((group) => group.items.map((item) => item.id)));
function tabFromLocation() {
if (typeof window === "undefined") return "creator-home";
const candidate = window.location.hash.replace(/^#/, "");
return navigationTabIds.has(candidate) ? candidate : "creator-home";
}
function inviteTokenFromLocation() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("invite") || "";
}
function portalTokenFromLocation() {
if (typeof window === "undefined") return "";
const match = window.location.pathname.match(/^\/portal\/([^/]+)\/?$/);
return match ? decodeURIComponent(match[1]) : "";
}
function ssoTicketFromLocation() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("sso_ticket") || "";
}
function ssoErrorFromLocation() {
if (typeof window === "undefined") return "";
const params = new URLSearchParams(window.location.search);
const code = params.get("sso_error");
if (!code) return "";
return params.get("sso_error_description") || `企业 SSO 登录失败:${code}`;
}
function normalizeAdapter(adapter) {
return {
...adapter,
kind: adapter.kind || adapter.protocol || "http-json",
baseUrl: adapter.baseUrl || adapter.endpoint || "",
enabled: adapter.enabled ?? (adapter.status === "ready"),
status: adapter.status || (adapter.enabled ? "ready" : "planned"),
costMode: adapter.costMode || adapter.cost_mode || "local",
approvalRequired: adapter.approvalRequired ?? Boolean(adapter.approval_required),
capability: adapter.capability || adapter.capabilities || []
};
}
function adapterCatalogForContext(platformContext) {
const registry = platformContext?.platform?.adapterCatalog || platformContext?.platform?.modelRegistry;
if (Array.isArray(registry) && registry.length) return registry.map(normalizeAdapter);
return adapters.map(normalizeAdapter);
}
function preferredAdapterId(catalog, requestedId) {
if (requestedId && catalog.some((adapter) => adapter.id === requestedId)) return requestedId;
const imageAdapter = catalog.find((adapter) => (adapter.capability || []).some((item) => /image-to-video|i2v|video/i.test(item)));
return imageAdapter?.id || catalog.find((adapter) => adapter.status === "ready")?.id || catalog[0]?.id || "owned-model-platform";
}
function StatusPill({ tone = "neutral", children }) {
return <span className={`status-pill ${tone}`}>{children}</span>;
}
function SectionTitle({ icon: Icon, title, action }) {
return (
<div className="section-title">
<div>
<Icon size={18} />
<h2>{title}</h2>
</div>
{action}
</div>
);
}
function Metric({ label, value, icon: Icon }) {
return (
<div className="metric">
<Icon size={18} />
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function ProgressBar({ value, tone = "ok" }) {
return (
<div className="progress-track">
<span className={tone} style={{ width: `${value}%` }} />
</div>
);
}
function Cockpit({ project, qaResults, setActiveTab, setActiveShotId, platformContext, canCreateJob, canApproveDelivery }) {
const platform = platformContext?.platform || platformData;
const platformSummary = platformContext?.summary || getPlatformSummary();
const organization = platform.organization || platformData.workspace;
const plan = platform.billing || platform.plan;
const totalDuration = project.shots.reduce((sum, shot) => sum + shot.durationSec, 0);
const blockedItems = project.pipeline.filter((item) => item.status === "blocked").length;
return (
<div className="cockpit">
<section className="command-hero">
<div className="command-copy">
<p className="eyebrow">LOCAL AI DRAMA OPS</p>
<h2>{project.series.title} · {project.episode.title}</h2>
<p>{organization.name || "私有化内容组织"}:把剧本、资产、模型、队列、审片、成本、合规和交付纳入同一个私有生产系统。</p>
<div className="hero-actions">
{canCreateJob && <button className="primary" onClick={() => setActiveTab("jobs")}>
<Play size={16} />
<span>进入生成队列</span>
</button>}
{canApproveDelivery && <button className="subtle" onClick={() => setActiveTab("export")}>
<Download size={16} />
<span>查看交付包</span>
</button>}
</div>
</div>
<div className="readiness-panel">
<span>商用准备度</span>
<strong>{project.production.commercialReadiness}%</strong>
<ProgressBar value={project.production.commercialReadiness} />
<p>{plan ? `${plan.plan_name || plan.name} · ${plan.seat_limit || plan.seats} seats · ${plan.storage_gb || plan.storageGb}GB storage · 外部云连接需显式审批。` : "当前角色无计费数据查看权限。"}</p>
</div>
</section>
<section className="platform-kpis">
<Metric icon={Layers3} label="生产项目" value={`${platformSummary.activeProjects} 个`} />
<Metric icon={Network} label="模型/Runner" value={`${platformSummary.modelCount}/${platformSummary.runnerCount}`} />
<Metric icon={TimerReset} label="队列深度" value={`${platformSummary.queueDepth}`} />
<Metric icon={Coins} label="预算使用" value={`${platformSummary.budget.used}/${platformSummary.budget.total}`} />
</section>
<section className="ops-grid">
<article className="ops-card">
<div className="ops-card-head">
<h3>生产流水线</h3>
<StatusPill tone={blockedItems ? "warn" : "ok"}>{blockedItems ? `${blockedItems} 个阻塞` : "运行中"}</StatusPill>
</div>
<div className="pipeline-list">
{project.pipeline.map((item) => {
const target = item.id === "script" ? "script" : item.id === "assets" ? "casting" : item.id === "qa" ? "qa" : item.id === "edit" ? "export" : "jobs";
return (
<button key={item.id} className={`pipeline-row ${item.status}`} onClick={() => setActiveTab(target)}>
<div>
<strong>{item.label}</strong>
<span>{item.owner} · {item.due}</span>
</div>
<ProgressBar value={item.progress} tone={item.status === "blocked" ? "warn" : "ok"} />
<b>{item.progress}%</b>
</button>
);})}
</div>
</article>
<article className="ops-card">
<div className="ops-card-head">
<h3>镜头生产状态</h3>
<StatusPill tone="ok">{project.shots.length} shots / {totalDuration}s</StatusPill>
</div>
<div className="shot-table">
{project.shots.map((shot) => {
const qa = qaResults.find((item) => item.shotId === shot.id);
return (
<button key={shot.id} onClick={() => { setActiveShotId(shot.id); setActiveTab("director"); }}>
<span>{shot.id}</span>
<strong>{shot.title}</strong>
<em>{shot.transitionFromPrevious}</em>
<b>{qa?.score || 0}</b>
</button>
);
})}
</div>
</article>
<article className="ops-card wide">
<div className="ops-card-head">
<h3>生成队列</h3>
<StatusPill tone="ok">local-only</StatusPill>
</div>
<div className="job-table">
<div className="job-head">
<span>任务</span>
<span>镜头</span>
<span>适配器</span>
<span>成本策略</span>
<span>状态</span>
</div>
{project.productionJobs.map((job) => (
<button key={job.id} onClick={() => { setActiveShotId(project.shots.find((shot) => shot.id === job.shotId)?.id || project.shots[0].id); setActiveTab("jobs"); }}>
<span>{job.kind}</span>
<span>{job.shotId}</span>
<span>{job.adapter}</span>
<span>{job.costPolicy}</span>
<StatusPill tone={job.status === "queued" ? "warn" : "ok"}>{job.status}</StatusPill>
</button>
))}
</div>
</article>
<article className="ops-card">
<div className="ops-card-head">
<h3>QA 证据</h3>
<StatusPill tone="warn">需要真实产物回填</StatusPill>
</div>
<div className="evidence-list">
{project.qaEvidence.map((item) => (
<div key={item.id} className={`evidence ${item.result}`}>
{item.result === "pass" ? <CheckCircle2 size={17} /> : <AlertTriangle size={17} />}
<div>
<strong>{item.label}</strong>
<p>{item.evidence}</p>
</div>
</div>
))}
</div>
</article>
<article className="ops-card">
<div className="ops-card-head">
<h3>交付物</h3>
<StatusPill tone="ok">{project.deliverables.filter((item) => item.status === "ready").length}/{project.deliverables.length}</StatusPill>
</div>
<div className="deliverable-list">
{project.deliverables.map((item) => (
<div key={item.path}>
<strong>{item.name}</strong>
<span>{item.path}</span>
<StatusPill tone={item.status === "ready" ? "ok" : "warn"}>{item.status}</StatusPill>
</div>
))}
</div>
</article>
<article className="ops-card wide benchmark-card">
<div className="ops-card-head">
<h3>市面商业平台对标</h3>
<StatusPill tone="ok">已转成本地版工作流</StatusPill>
</div>
<div className="benchmark-grid">
{(platform.researchMatrix || platformSummary.researchMatrix || []).map((item) => (
<div key={item.category}>
<strong>{item.category}</strong>
<span>{item.commercialNeed}</span>
<p>{item.localBuild}</p>
</div>
))}
</div>
</article>
</section>
</div>
);
}
function ProjectFactory({ setActiveTab, platformContext }) {
const platform = platformContext?.platform || platformData;
const projects = platform.projects || platformData.projects;
return (
<div className="platform-stage">
<SectionTitle icon={Layers3} title="项目工厂" />
<div className="factory-layout">
<section className="studio-card">
<h3>商业项目入口</h3>
<div className="project-template-grid">
{["原创 AI 漫剧", "小说解说漫", "动态漫拆层", "仿真人短剧审慎模式", "平台 Agent 流水线"].map((name) => (
<button key={name} onClick={() => setActiveTab("script")}>
<Film size={20} />
<strong>{name}</strong>
<span>创建项目 · 绑定模板 · 生成资产目录</span>
</button>
))}
</div>
</section>
<section className="studio-card">
<h3>项目资产空间</h3>
<div className="bucket-list">
{platformData.assetStorage.buckets.map((bucket) => (
<div key={bucket.id}>
<HardDrive size={17} />
<strong>{bucket.label}</strong>
<span>{bucket.fileCount} files</span>
<em>{bucket.policy}</em>
</div>
))}
</div>
</section>
<section className="studio-card wide-card">
<h3>项目列表</h3>
<div className="platform-table">
<div>
<span>项目</span><span>类型</span><span>阶段</span><span>准备度</span><span>风险</span><span>更新</span>
</div>
{projects.map((project) => (
<button key={project.id} onClick={() => setActiveTab("director")}>
<strong>{project.title || project.name}</strong>
<span>{project.type}</span>
<span>{project.stage || project.status}</span>
<span>{project.readiness}%</span>
<StatusPill tone={project.risk === "low" ? "ok" : "warn"}>{project.risk}</StatusPill>
<span>{project.updatedAt || project.updated_at?.slice(0, 10)}</span>
</button>
))}
</div>
</section>
</div>
</div>
);
}
function ScriptStudio({ project, canEditAssets }) {
return (
<div className="platform-stage">
<SectionTitle icon={BookOpen} title="剧本拆解工厂" />
<div className="stage-grid">
<section className="studio-card import-card">
<h3>长文本导入</h3>
<p>{project.scriptStudio.sourceType} · {project.scriptStudio.importLimit}</p>
<textarea defaultValue={`第 1 集《${project.episode.title}》\n\n${project.episode.hook}\n\n陈宇:等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。\n唐夏:那我现在该往哪走?\n陈宇:看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。`} />
<div className="button-row">
<button className="primary"><Sparkles size={16} />智能拆章</button>
{canEditAssets && <button className="subtle"><ListChecks size={16} />抽取资产</button>}
</div>
</section>
<section className="studio-card">
<h3>章节拆分</h3>
<div className="chapter-list">
{project.scriptStudio.chapters.map((chapter) => (
<div key={chapter.id}>
<strong>{chapter.title}</strong>
<span>{chapter.words} 字 · {chapter.scenes} 场 · {chapter.status}</span>
</div>
))}
</div>
</section>
<section className="studio-card">
<h3>资产抽取</h3>
<div className="extract-list">
{project.scriptStudio.extracted.map((item) => (
<div key={`${item.type}-${item.name}`}>
<span>{item.type}</span>
<strong>{item.name}</strong>
<b>{Math.round(item.confidence * 100)}%</b>
<em>{item.target}</em>
</div>
))}
</div>
</section>
<section className="studio-card">
<h3>风格设定</h3>
<p>{project.scriptStudio.stylePreset}</p>
<p>{project.scriptStudio.splitRule}</p>
<div className="style-swatches">
<span></span><span></span><span></span><span></span>
</div>
</section>
</div>
</div>
);
}
function CastingBoard({ project }) {
return (
<div className="platform-stage">
<SectionTitle icon={ShieldCheck} title="资产选角与一致性库" />
<div className="casting-grid">
{project.characters.map((character) => (
<article className="casting-card" key={character.id}>
<div className="portrait-system">
<div className="portrait front"><span>{character.name.slice(0, 1)}</span></div>
<div className="turnaround">
{character.casting.turnarounds.map((item) => <span key={item}>{item}</span>)}
</div>
</div>
<div>
<h3>{character.name} · {character.role}</h3>
<p>{character.visualLock}</p>
<div className="wardrobe-list">
{character.casting.wardrobe.map((item) => <span key={item}>{item}</span>)}
</div>
<dl>
<dt>声线</dt>
<dd>{character.voiceLock.voiceId}</dd>
<dt>复用</dt>
<dd>{character.casting.reuseScope}</dd>
</dl>
</div>
</article>
))}
</div>
<div className="asset-grid">
<section className="studio-card">
<h3>场景库</h3>
{project.locations.map((location) => (
<div className="asset-row" key={location.id}>
<strong>{location.name}</strong>
<span>{location.weather}</span>
<p>{location.cameraLock}</p>
</div>
))}
</section>
<section className="studio-card">
<h3>道具库</h3>
{project.props.map((prop) => (
<div className="asset-row" key={prop.id}>
<strong>{prop.name}</strong>
<p>{prop.lock}</p>
</div>
))}
</section>
</div>
</div>
);
}
function ModelOps({ platformContext }) {
const platform = platformContext?.platform || platformData;
const models = platform.modelRegistry || platformData.modelRegistry;
const runners = platform.runnerHealth || platformData.runnerHealth;
return (
<div className="platform-stage">
<SectionTitle icon={Network} title="模型中台与 Runner 编排" />
<div className="modelops-grid">
<section className="studio-card wide-card">
<h3>模型注册表</h3>
<div className="platform-table model-table">
<div><span>模型</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span></div>
{models.map((model) => (
<button key={model.id}>
<strong>{model.label}</strong>
<span>{model.capability.join(", ")}</span>
<span>{model.endpoint}</span>
<span>{model.costMode || model.cost_mode}</span>
<span>{model.approvalRequired || model.approval_required ? "required" : "no"}</span>
<StatusPill tone={model.status === "not-connected" ? "warn" : "ok"}>{model.status}</StatusPill>
</button>
))}
</div>
</section>
<section className="studio-card">
<h3>Runner Health</h3>
<div className="runner-list">
{runners.map((runner) => (
<div key={runner.id}>
<strong>{runner.name}</strong>
<span>{runner.status}</span>
<ProgressBar value={Math.min(100, runner.queueDepth * 16 + 12)} tone={runner.status.includes("waiting") ? "warn" : "ok"} />
<em>queue {runner.queueDepth} · {runner.lastHeartbeat}</em>
</div>
))}
</div>
</section>
</div>
</div>
);
}
function ComplianceCenter({ platformContext }) {
const platform = platformContext?.platform || platformData;
const plan = platform.billing || platform.plan || platformData.plan;
const costCenters = platform.costCenters || platformData.costCenters;
const policies = platform.compliancePolicies || platformData.compliancePolicies;
return (
<div className="platform-stage">
<SectionTitle icon={Scale} title="成本、额度与合规中心" />
<div className="compliance-grid">
<section className="studio-card">
<h3>套餐与额度</h3>
<dl>
<dt>套餐</dt><dd>{plan.plan_name || plan.name}</dd>
<dt>Seats</dt><dd>{plan.seat_limit || plan.seats}</dd>
<dt>存储</dt><dd>{plan.storage_gb || plan.storageGb} GB</dd>
<dt>片段额度</dt><dd>{plan.monthly_clip_quota || plan.monthlyClipQuota} clips/月</dd>
<dt>云连接</dt><dd>{plan.cloud_connectors_require_approval ?? plan.cloudConnectorsRequireApproval ? "必须审批" : "可用"}</dd>
</dl>
</section>
<section className="studio-card">
<h3>成本中心</h3>
<div className="runner-list">
{costCenters.map((cost) => (
<div key={cost.id}>
<strong>{cost.label}</strong>
<ProgressBar value={cost.monthBudget ? Math.round((cost.used / cost.monthBudget) * 100) : 0} tone={cost.id === "cc-cloud" ? "warn" : "ok"} />
<em>{cost.used}/{cost.monthBudget} {cost.unit}</em>
</div>
))}
</div>
</section>
<section className="studio-card wide-card">
<h3>合规策略</h3>
<div className="policy-grid">
{policies.map((policy) => (
<div key={policy.id}>
<strong>{policy.label}</strong>
<span>{policy.severity}</span>
<StatusPill tone={policy.status === "enforced" ? "ok" : "warn"}>{policy.status}</StatusPill>
</div>
))}
</div>
</section>
</div>
</div>
);
}
function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvite, onResendInvitation, onRevokeInvitation, onCreateOrganization, onCreateWorkspace, onCreateProject, onUpdateOrganization, onUpdateWorkspace, onUpdateProject, onUpdateOrganizationMember, onUpdateOrganizationRolePolicy, onUpdateWorkspaceMember, onUpdateProjectMember, onAddProjectMember }) {
const platform = platformContext?.platform || platformData;
const context = platformContext?.context;
const organizations = platform.organizations || [{ id: "ws-local-aidrama", name: platform.workspace?.name || "本地工作区", role_name: "平台管理员", workspace_count: 1 }];
const workspaces = platform.workspaces || [];
const projects = platform.projects || platformData.projects;
const members = platform.members || platformData.members;
const workspaceMembers = platform.workspaceMembers || [];
const projectMembers = platform.projectMembers || [];
const invitations = platform.invitations || [];
const auditLog = platform.auditLog || platformData.auditLog;
const rolePolicies = platform.rolePolicies || { roles: [], permissions: [] };
const permissionMatrix = platformContext?.rolePermissions?.length
? platformContext.rolePermissions.map((role) => ({ role: role.name, permissions: role.permissions || [] }))
: platformData.permissionMatrix;
const billing = platform.billing || platform.plan || platformData.plan;
const usage = platform.usage;
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("writer");
const [inviteScope, setInviteScope] = useState(workspaces.length ? "workspace" : "organization");
const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || "");
const [inviteProjectId, setInviteProjectId] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [workspaceName, setWorkspaceName] = useState("");
const [projectName, setProjectName] = useState("");
const [notice, setNotice] = useState("");
const [memberDrafts, setMemberDrafts] = useState({});
const [projectMemberDrafts, setProjectMemberDrafts] = useState({});
const [workspaceMemberDrafts, setWorkspaceMemberDrafts] = useState({});
const [organizationDraft, setOrganizationDraft] = useState({ name: "", slug: "" });
const [workspaceDraft, setWorkspaceDraft] = useState({ name: "", slug: "", description: "", status: "active" });
const [projectDraft, setProjectDraft] = useState({ name: "", type: "AI 漫剧", status: "production", visibility: "workspace", readiness: 0, risk: "low" });
const [newProjectMemberId, setNewProjectMemberId] = useState("");
const [selectedPolicyRoleKey, setSelectedPolicyRoleKey] = useState("");
const [policySearch, setPolicySearch] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [inviteActionId, setInviteActionId] = useState("");
const inviteProjects = projects.filter((item) => !inviteWorkspaceId || item.workspace_id === inviteWorkspaceId || item.workspaceId === inviteWorkspaceId);
const inviteRoleOptions = inviteScope === "organization"
? [["org_member", "组织成员"], ["org_admin", "组织管理员"]]
: inviteScope === "project"
? [["project_editor", "项目编辑"], ["project_viewer", "项目查看者"]]
: [["writer", "编剧"], ["producer", "制片"], ["art_director", "资产美术"], ["voice_editor", "配音/字幕"], ["reviewer", "审片"]];
useEffect(() => {
setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }])));
}, [platform.members]);
useEffect(() => {
setProjectMemberDrafts(Object.fromEntries(projectMembers.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "project_viewer", status: member.status || "active" }])));
}, [platform.projectMembers]);
useEffect(() => {
const organization = context?.currentOrganization;
const workspace = context?.currentWorkspace;
const project = context?.currentProject;
setOrganizationDraft({ name: organization?.name || "", slug: organization?.slug || "" });
setWorkspaceDraft({ name: workspace?.name || "", slug: workspace?.slug || "", description: workspace?.description || "", status: workspace?.status || "active" });
setProjectDraft({ name: project?.name || "", type: project?.type || "AI 漫剧", status: project?.status || "production", visibility: project?.visibility || "workspace", readiness: project?.readiness ?? 0, risk: project?.risk || "low" });
}, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]);
useEffect(() => {
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" }])));
}, [platform.workspaceMembers]);
useEffect(() => {
if (!workspaces.some((workspace) => workspace.id === inviteWorkspaceId)) setInviteWorkspaceId(workspaces[0]?.id || "");
}, [platform.workspaces]);
useEffect(() => {
if (!inviteProjects.some((project) => project.id === inviteProjectId)) setInviteProjectId(inviteProjects[0]?.id || "");
}, [platform.projects, inviteWorkspaceId]);
useEffect(() => {
const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0];
if (availableRole && availableRole.key !== selectedPolicyRoleKey) setSelectedPolicyRoleKey(availableRole.key);
if (!rolePolicies.roles.length) setSelectedPolicyRoleKey("");
}, [rolePolicies.roles, selectedPolicyRoleKey]);
async function runAction(action, successMessage) {
try {
const result = await action();
setNotice(successMessage);
return result;
} catch (error) {
setNotice(error.message);
return null;
}
}
function changeInviteScope(scope) {
setInviteScope(scope);
if (scope === "organization") setInviteRole("org_member");
else if (scope === "project") setInviteRole("project_editor");
else setInviteRole("writer");
}
function inviteUrl(invitation) {
if (!invitation) return "";
const path = invitation.acceptUrl || (invitation.inviteToken ? `/register?invite=${encodeURIComponent(invitation.inviteToken)}` : "");
if (!path) return "";
if (typeof window === "undefined") return path;
return new URL(path, window.location.origin).toString();
}
const handleInvite = async (event) => {
event.preventDefault();
if (inviteScope !== "organization" && !inviteWorkspaceId) {
setNotice("工作区级或项目级邀请必须选择工作区");
return;
}
if (inviteScope === "project" && !inviteProjectId) {
setNotice("项目级邀请必须选择项目");
return;
}
const body = { email: inviteEmail, roleKey: inviteRole };
if (inviteScope !== "organization") body.workspaceId = inviteWorkspaceId;
if (inviteScope === "project") body.projectId = inviteProjectId;
const result = await runAction(() => onInvite(body), "邀请已创建;注册链接仅在当前管理会话中显示");
if (result?.invitation) {
setInviteLink(inviteUrl(result.invitation));
setInviteEmail("");
}
};
async function handleResendInvitation(invitation) {
setInviteActionId(invitation.id);
try {
const result = await runAction(() => onResendInvitation?.(invitation.id), "邀请已重新发送;旧注册链接已立即失效");
if (result?.invitation) setInviteLink(inviteUrl(result.invitation));
} finally {
setInviteActionId("");
}
}
async function handleRevokeInvitation(invitation) {
if (typeof window !== "undefined" && !window.confirm(`确定撤销发给 ${invitation.email} 的邀请吗?撤销后注册链接立即失效,席位会释放。`)) return;
setInviteActionId(invitation.id);
try {
const result = await runAction(() => onRevokeInvitation?.(invitation.id), "邀请已撤销,预留席位已释放");
if (result?.invitation) setInviteLink("");
} finally {
setInviteActionId("");
}
}
async function copyInviteLink() {
if (!inviteLink) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(inviteLink);
} else {
const input = document.querySelector("[data-invite-link]");
input?.select();
document.execCommand("copy");
}
setNotice("注册链接已复制");
} catch {
setNotice("浏览器未授权复制,请手动选择并复制注册链接");
}
}
const handleWorkspace = (event) => {
event.preventDefault();
runAction(() => onCreateWorkspace({ name: workspaceName }), "工作区已创建");
setWorkspaceName("");
};
const handleOrganization = (event) => {
event.preventDefault();
runAction(() => onCreateOrganization({ name: organizationName }), "组织已创建并切换");
setOrganizationName("");
};
const handleProject = (event) => {
event.preventDefault();
runAction(() => onCreateProject({ name: projectName, type: "AI 漫剧" }), "项目已创建");
setProjectName("");
};
function updateMemberDraft(userId, field, value) {
setMemberDrafts((current) => ({ ...current, [userId]: { ...(current[userId] || {}), [field]: value } }));
}
function updateProjectMemberDraft(userId, field, value) {
setProjectMemberDrafts((current) => ({ ...current, [userId]: { ...(current[userId] || {}), [field]: value } }));
}
function saveMember(member) {
const userId = member.user_id || member.id;
const draft = memberDrafts[userId];
runAction(() => onUpdateOrganizationMember(userId, draft), `${member.display_name || member.name} 的组织角色已更新`);
}
function saveProjectMember(member) {
const userId = member.user_id || member.id;
const draft = projectMemberDrafts[userId];
runAction(() => onUpdateProjectMember(userId, draft), `${member.display_name} 的项目角色已更新`);
}
function saveWorkspaceMember(member) {
const userId = member.user_id || member.id;
const draft = workspaceMemberDrafts[userId];
runAction(() => onUpdateWorkspaceMember?.(userId, draft), `${member.display_name} 的工作区角色已更新`);
}
function saveOrganizationSettings(event) {
event.preventDefault();
runAction(() => onUpdateOrganization?.(organizationDraft), "组织设置已更新");
}
function saveWorkspaceSettings(event) {
event.preventDefault();
runAction(() => onUpdateWorkspace?.(workspaceDraft), "工作区设置已更新");
}
function saveProjectSettings(event) {
event.preventDefault();
runAction(() => onUpdateProject?.(projectDraft), "项目设置已更新");
}
function addProjectMember(event) {
event.preventDefault();
if (!newProjectMemberId) return;
runAction(() => onAddProjectMember?.(newProjectMemberId, { roleKey: "project_editor" }), "项目成员已加入");
setNewProjectMemberId("");
}
const selectedPolicyRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0] || null;
const normalizedPolicySearch = policySearch.trim().toLowerCase();
const visiblePolicyPermissions = rolePolicies.permissions.filter((permission) => {
if (!normalizedPolicySearch) return true;
return `${permission.key} ${permission.description}`.toLowerCase().includes(normalizedPolicySearch);
});
const policyOverrides = new Map((selectedPolicyRole?.overrides || []).map((override) => [override.permissionKey, override]));
const canManageRolePolicies = Boolean(context?.permissions?.includes("organization:roles:manage"));
function savePolicyPermission(permission, enabled) {
if (!selectedPolicyRole || !onUpdateOrganizationRolePolicy) return;
runAction(
() => onUpdateOrganizationRolePolicy(selectedPolicyRole.key, { permissionKey: permission.key, enabled }),
`${selectedPolicyRole.name} · ${permission.description} 已${enabled ? "启用" : "撤销"}`
);
}
return (
<div className="platform-stage">
<SectionTitle
icon={KeyRound}
title="平台管理"
action={<button className="subtle" onClick={onRefresh}><RefreshCw size={15} />刷新租户数据</button>}
/>
<div className="admin-context-strip">
<div className="admin-context-main">
<Building2 size={22} />
<div>
<span>当前组织</span>
<strong>{context?.currentOrganization?.name || platform.workspace?.name || "本地组织"}</strong>
<p>{context?.currentOrganization?.slug || "private-local"} · {context?.organizationRole || "platform-admin"}</p>
</div>
</div>
<div className="admin-context-stats">
<div><strong>{organizations.length}</strong><span>可访问组织</span></div>
<div><strong>{workspaces.length}</strong><span>当前组织工作区</span></div>
<div><strong>{members.length}</strong><span>组织成员</span></div>
<div><strong>{context?.permissions?.length || permissionMatrix[0].permissions.length}</strong><span>当前权限</span></div>
</div>
</div>
{notice && <div className="admin-notice"><CheckCircle2 size={16} />{notice}</div>}
<div className="admin-grid">
<section className="studio-card wide-card">
<div className="card-heading-row"><div><span className="card-kicker">TENANT DIRECTORY</span><h3>组织与工作区</h3></div><StatusPill tone="ok">scope enforced</StatusPill></div>
<div className="organization-grid">
{organizations.map((organization) => (
<button key={organization.id} className={`organization-tile ${organization.id === context?.currentOrganization?.id ? "selected" : ""}`} onClick={() => onSwitchOrganization(organization.id)}>
<Building2 size={18} />
<strong>{organization.name}</strong>
<span>{organization.role_name || organization.role || "成员"} · {organization.workspace_count || 0} 个工作区</span>
<em>{organization.slug}</em>
</button>
))}
</div>
<div className="workspace-admin-row">
<div className="admin-form-stack">
<form onSubmit={handleOrganization} className="inline-admin-form">
<input required value={organizationName} onChange={(event) => setOrganizationName(event.target.value)} placeholder="新组织名称" aria-label="新组织名称" />
<button className="subtle" type="submit"><Building2 size={15} />创建组织</button>
</form>
<form onSubmit={handleWorkspace} className="inline-admin-form">
<input required value={workspaceName} onChange={(event) => setWorkspaceName(event.target.value)} placeholder="新工作区名称" aria-label="新工作区名称" />
<button className="primary" type="submit"><Plus size={15} />创建工作区</button>
</form>
</div>
<div className="workspace-chips">
{workspaces.map((workspace) => <span key={workspace.id}><FolderKanban size={14} />{workspace.name}<small>{workspace.projectCount || 0} 项目</small></span>)}
</div>
</div>
</section>
<section className="studio-card wide-card">
<div className="card-heading-row"><div><span className="card-kicker">TENANT SETTINGS</span><h3>组织、工作区与项目设置</h3></div><Settings2 size={18} /></div>
<div className="tenant-settings-grid">
<form onSubmit={saveOrganizationSettings} className="tenant-settings-form">
<strong>当前组织</strong>
<label>名称<input value={organizationDraft.name} onChange={(event) => setOrganizationDraft({ ...organizationDraft, name: event.target.value })} /></label>
<label>组织标识<input value={organizationDraft.slug} onChange={(event) => setOrganizationDraft({ ...organizationDraft, slug: event.target.value })} /></label>
<button className="subtle" type="submit"><Save size={14} />保存组织</button>
</form>
<form onSubmit={saveWorkspaceSettings} className="tenant-settings-form">
<strong>当前工作区</strong>
<label>名称<input value={workspaceDraft.name} onChange={(event) => setWorkspaceDraft({ ...workspaceDraft, name: event.target.value })} /></label>
<label>工作区标识<input value={workspaceDraft.slug} onChange={(event) => setWorkspaceDraft({ ...workspaceDraft, slug: event.target.value })} /></label>
<label>描述<textarea rows="2" value={workspaceDraft.description} onChange={(event) => setWorkspaceDraft({ ...workspaceDraft, description: event.target.value })} /></label>
<div className="form-actions"><select value={workspaceDraft.status} onChange={(event) => setWorkspaceDraft({ ...workspaceDraft, status: event.target.value })}><option value="active">启用</option><option value="archived">归档</option></select><button className="subtle" type="submit"><Save size={14} />保存工作区</button></div>
</form>
<form onSubmit={saveProjectSettings} className="tenant-settings-form">
<strong>当前项目</strong>
<label>名称<input value={projectDraft.name} onChange={(event) => setProjectDraft({ ...projectDraft, name: event.target.value })} /></label>
<label>类型<input value={projectDraft.type} onChange={(event) => setProjectDraft({ ...projectDraft, type: event.target.value })} /></label>
<div className="form-grid"><label>状态<select value={projectDraft.status} onChange={(event) => setProjectDraft({ ...projectDraft, status: event.target.value })}><option value="draft">草稿</option><option value="production">制作中</option><option value="paused">暂停</option><option value="archived">归档</option></select></label><label>风险<select value={projectDraft.risk} onChange={(event) => setProjectDraft({ ...projectDraft, risk: event.target.value })}><option value="low">低</option><option value="medium">中</option><option value="high">高</option></select></label></div>
<label>准备度<input type="number" min="0" max="100" value={projectDraft.readiness} onChange={(event) => setProjectDraft({ ...projectDraft, readiness: Number(event.target.value) })} /></label>
<button className="subtle" type="submit"><Save size={14} />保存项目</button>
</form>
</div>
</section>
<section className="studio-card">
<div className="card-heading-row"><div><span className="card-kicker">ACCESS CONTROL</span><h3>成员与角色</h3></div><Users size={18} /></div>
<div className="member-list">
{members.map((member) => {
const name = member.display_name || member.name;
const role = member.role_name || member.role;
const status = member.status || "active";
const userId = member.user_id || member.id;
const draft = memberDrafts[userId] || { roleKey: member.role_key || "org_member", status };
const isOwner = member.role_key === "org_owner";
return <div key={member.id || member.user_id}><span className="member-avatar">{name?.slice(0, 1)}</span><div><strong>{name}</strong><span>{member.email || role}</span></div><div className="member-role-editor"><select aria-label={`${name} 组织角色`} value={draft.roleKey} disabled={isOwner} onChange={(event) => updateMemberDraft(userId, "roleKey", event.target.value)}><option value="org_owner">组织所有者</option><option value="org_admin">组织管理员</option><option value="org_member">组织成员</option></select><select aria-label={`${name} 成员状态`} value={draft.status} onChange={(event) => updateMemberDraft(userId, "status", event.target.value)}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveMember(member)} disabled={isOwner && draft.status === "active"}><Save size={13} />保存</button></div></div>;
})}
</div>
<form className="invite-form" onSubmit={handleInvite}>
<div className="form-title"><UserPlus size={16} />邀请成员</div>
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteScope} onChange={(event) => changeInviteScope(event.target.value)} aria-label="邀请范围"><option value="organization">组织级</option><option value="workspace" disabled={!workspaces.length}>工作区级</option><option value="project" disabled={!inviteProjects.length}>项目级</option></select></div>
{inviteScope !== "organization" && <select value={inviteWorkspaceId} onChange={(event) => { setInviteWorkspaceId(event.target.value); if (inviteScope === "project") setInviteProjectId(""); }} aria-label="邀请工作区"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>}
{inviteScope === "project" && <select value={inviteProjectId} onChange={(event) => setInviteProjectId(event.target.value)} aria-label="邀请项目"><option value="">选择项目</option>{inviteProjects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select>}
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色">{inviteRoleOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
<button className="subtle" type="submit"><Mail size={15} />发送邀请</button>
</form>
{inviteLink && <div className="invite-link-card">
<div>
<span className="card-kicker">LOCAL INVITE LINK</span>
<strong>注册链接已生成</strong>
<p>本地部署不会自动发邮件,请复制下面的地址发送给成员。链接有效期 7 天,重发后旧链接立即失效。</p>
</div>
<div className="invite-link-row">
<input data-invite-link value={inviteLink} readOnly aria-label="邀请注册链接" onFocus={(event) => event.target.select()} />
<button type="button" className="subtle" onClick={copyInviteLink}><Copy size={14} />复制链接</button>
</div>
</div>}
</section>
<section className="studio-card">
<div className="card-heading-row"><div><span className="card-kicker">PENDING ACCESS</span><h3>待处理邀请</h3></div><StatusPill tone={invitations.length ? "warn" : "ok"}>{invitations.length} pending</StatusPill></div>
<div className="invite-list">
{invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}>
<Mail size={15} />
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.project_name ? `${invite.workspace_name} / ${invite.project_name}` : invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
<div className="invite-actions"><StatusPill tone="warn">pending</StatusPill><button type="button" className="icon-text-button" onClick={() => handleResendInvitation(invite)} disabled={inviteActionId === invite.id}><RefreshCw size={13} />重发</button><button type="button" className="icon-text-button danger" onClick={() => handleRevokeInvitation(invite)} disabled={inviteActionId === invite.id}><XCircle size={13} />撤销</button></div>
</div>) : <p className="empty-state">当前组织没有待处理邀请。</p>}
</div>
</section>
<section className="studio-card">
<div className="card-heading-row"><div><span className="card-kicker">WORKSPACE ACCESS</span><h3>当前工作区成员</h3></div><StatusPill tone="ok">{workspaceMembers.length} members</StatusPill></div>
<div className="member-list workspace-member-list">
{workspaceMembers.length ? workspaceMembers.map((member) => {
const userId = member.user_id || member.id;
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" };
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="project_guest">项目受限成员</option></select><select aria-label={`${member.display_name} 项目访问范围`} value={draft.accessMode} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, accessMode: event.target.value } }))}><option value="all">全部项目</option><option value="project-only">仅授权项目</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
}) : <p className="empty-state">当前角色没有工作区成员管理权限。</p>}
</div>
</section>
<section className="studio-card wide-card">
<div className="card-heading-row"><div><span className="card-kicker">PROJECT ACCESS</span><h3>项目与访问边界</h3></div><FolderKanban size={18} /></div>
<form onSubmit={handleProject} className="inline-admin-form project-create-form"><input required value={projectName} onChange={(event) => setProjectName(event.target.value)} placeholder="新项目名称" aria-label="新项目名称" /><button className="primary" type="submit"><Plus size={15} />创建项目</button></form>
<div className="platform-table project-access-table">
<div><span>项目</span><span>类型</span><span>状态</span><span>所有者</span><span>成员</span><span>风险</span></div>
{projects.map((project) => <button key={project.id}><strong>{project.title || project.name}</strong><span>{project.type}</span><span>{project.stage || project.status}</span><span>{project.owner || project.owner_user_id}</span><span>{project.memberCount || "-"}</span><StatusPill tone={project.risk === "low" ? "ok" : "warn"}>{project.risk}</StatusPill></button>)}
</div>
<div className="project-members-admin">
<div className="card-heading-row"><div><span className="card-kicker">CURRENT PROJECT</span><h3>当前项目成员角色</h3></div><StatusPill tone="ok">{projectMembers.length} members</StatusPill></div>
{projectMembers.length ? projectMembers.map((member) => { const userId = member.user_id || member.id; const draft = projectMemberDrafts[userId] || { roleKey: member.role_key || "project_viewer", status: member.status || "active" }; return <div className="project-member-row" key={member.id || member.user_id}><div><strong>{member.display_name}</strong><span>{member.email} · {member.role_name}</span></div><select aria-label={`${member.display_name} 项目角色`} value={draft.roleKey} onChange={(event) => updateProjectMemberDraft(userId, "roleKey", event.target.value)}><option value="project_editor">项目编辑</option><option value="project_viewer">项目查看者</option></select><select aria-label={`${member.display_name} 项目状态`} value={draft.status} onChange={(event) => updateProjectMemberDraft(userId, "status", event.target.value)}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveProjectMember(member)}><Save size={13} />保存</button></div>; }) : <p className="empty-state">当前角色没有项目成员管理权限,或当前项目还没有显式成员。</p>}
</div>
{members.length > projectMembers.length && <form className="invite-form project-member-add-form" onSubmit={addProjectMember}><div className="form-title"><UserPlus size={16} />添加项目成员</div><div className="form-grid"><select value={newProjectMemberId} onChange={(event) => setNewProjectMemberId(event.target.value)} aria-label="选择项目成员"><option value="">选择组织成员</option>{members.filter((member) => !projectMembers.some((existing) => (existing.user_id || existing.id) === (member.user_id || member.id))).map((member) => <option key={member.user_id || member.id} value={member.user_id || member.id}>{member.display_name || member.name} · {member.email}</option>)}</select><button className="subtle" type="submit" disabled={!newProjectMemberId}><UserPlus size={15} />加入项目</button></div></form>}
</section>
<section className="studio-card">
<div className="card-heading-row"><div><span className="card-kicker">ROLE MATRIX</span><h3>权限矩阵</h3></div><ShieldCheck size={18} /></div>
<div className="permission-list">
{permissionMatrix.map((role) => <div key={role.role}><strong>{role.role}</strong><p>{role.permissions.join(" · ")}</p></div>)}
</div>
<div className="effective-permissions"><span>当前用户有效权限</span><strong>{context?.roles?.map((role) => role.name).join(" / ") || "平台管理员"}</strong><p>{context?.permissions?.slice(0, 8).join(" · ") || "workspace:* · project:* · model:*"}</p></div>
</section>
<section className="studio-card wide-card role-policy-editor">
<div className="card-heading-row">
<div><span className="card-kicker">ORGANIZATION POLICY</span><h3>组织角色权限策略</h3></div>
<StatusPill tone={canManageRolePolicies ? "ok" : "warn"}>{canManageRolePolicies ? "可管理" : "只读"}</StatusPill>
</div>
{rolePolicies.roles.length ? <div className="role-policy-layout">
<div className="role-policy-roles">
<div className="role-policy-panel-title"><span>角色</span><small>{rolePolicies.roles.length} 个角色</small></div>
{rolePolicies.roles.map((role) => <button key={role.key} className={`role-policy-role ${role.key === selectedPolicyRole?.key ? "selected" : ""}`} onClick={() => setSelectedPolicyRoleKey(role.key)}>
<span><strong>{role.name}</strong><em>{role.scope === "organization" ? "组织" : role.scope === "workspace" ? "工作区" : "项目"}</em></span>
<small>{role.permissions.length} 项有效权限</small>
</button>)}
</div>
<div className="role-policy-permissions">
<div className="role-policy-editor-head">
<div><strong>{selectedPolicyRole?.name || "选择角色"}</strong><span>{selectedPolicyRole?.description || ""}</span></div>
<label className="policy-search"><Search size={15} /><input value={policySearch} onChange={(event) => setPolicySearch(event.target.value)} placeholder="搜索权限" aria-label="搜索权限" /></label>
</div>
{selectedPolicyRole && <div className="role-policy-locks">
<span>{selectedPolicyRole.key === "org_owner" ? "组织所有者策略已锁定" : "组织级覆盖不修改内置角色模板"}</span>
<span>{policyOverrides.size} 项组织覆盖</span>
</div>}
<div className="permission-check-grid">
{visiblePolicyPermissions.map((permission) => {
const checked = selectedPolicyRole?.permissions.includes(permission.key);
const override = policyOverrides.get(permission.key);
const locked = !canManageRolePolicies || selectedPolicyRole?.key === "org_owner" || permission.systemOnly;
return <label className={`permission-check ${locked ? "locked" : ""}`} key={permission.key}>
<input type="checkbox" checked={Boolean(checked)} disabled={locked} onChange={(event) => savePolicyPermission(permission, event.target.checked)} />
<span className="permission-check-copy"><strong>{permission.key}</strong><small>{permission.description}</small></span>
<span className={`permission-origin ${override ? "overridden" : "inherited"}`}>{permission.systemOnly ? "系统锁定" : override ? "组织覆盖" : "内置"}</span>
</label>;
})}
</div>
{!visiblePolicyPermissions.length && <p className="empty-state">没有匹配的权限。</p>}
</div>
</div> : <div className="empty-state">当前账号没有组织角色策略管理权限。</div>}
</section>
<section className="studio-card">
<div className="card-heading-row"><div><span className="card-kicker">BILLING & USAGE</span><h3>套餐与用量</h3></div><Coins size={18} /></div>
<dl className="compact-definition-list"><dt>套餐</dt><dd>{billing.plan_name || billing.name}</dd><dt>席位</dt><dd>{billing.seat_limit || billing.seats}</dd><dt>存储</dt><dd>{billing.storage_gb || billing.storageGb} GB</dd><dt>本月任务</dt><dd>{usage?.totalCost ?? "-"} 计量成本</dd></dl>
<div className="quota-list">{usage?.quotas?.map((quota) => <div key={`${quota.metric}-${quota.unit}`}><span>{quota.metric}</span><ProgressBar value={Math.min(100, Math.round((quota.used_value / quota.limit_value) * 100))} /><em>{quota.used_value}/{quota.limit_value} {quota.unit}</em></div>)}</div>
</section>
<section className="studio-card wide-card">
<div className="card-heading-row"><div><span className="card-kicker">AUDIT TRAIL</span><h3>审计日志</h3></div><StatusPill tone="ok">immutable events</StatusPill></div>
<div className="audit-list">
{auditLog.map((item) => <div key={item.id}><span>{item.created_at || item.time}</span><strong>{item.action}</strong><span>{item.target_id || item.target}</span><StatusPill tone={item.result === "ok" || item.result === "pass" ? "ok" : "warn"}>{item.result}</StatusPill></div>)}
</div>
</section>
</div>
</div>
);
}
function DirectorDesk({ project, activeShot, setActiveShotId }) {
return (
<div className="platform-stage">
<SectionTitle icon={Clapperboard} title="导演工作台" />
<div className="director-toolbar">
{project.directorDesk.batchActions.map((action) => (
<button key={action} className="subtle">{action}</button>
))}
</div>
<div className="director-board">
<div className="director-table">
<div className="director-head">
{project.directorDesk.columns.map((column) => <span key={column}>{column}</span>)}
</div>
{project.shots.map((shot) => (
<button key={shot.id} className={shot.id === activeShot.id ? "selected" : ""} onClick={() => setActiveShotId(shot.id)}>
<span>{shot.id}</span>
<span>{shot.characterIds.join(", ")}</span>
<span>{shot.locationId}</span>
<span>{shot.firstFrame}</span>
<span>{shot.lastFrame}</span>
<StatusPill tone="warn">待生成</StatusPill>
<StatusPill tone="ok">voice locked</StatusPill>
<StatusPill tone="ok">QA</StatusPill>
<span>{shot.transitionFromPrevious}</span>
</button>
))}
</div>
<div className="director-preview">
<ScenePreview shot={activeShot} active />
<div className="detail-grid">
<div>
<h3>镜头动作</h3>
<p>{activeShot.action}</p>
</div>
<div>
<h3>视频提示</h3>
<p>{activeShot.videoPrompt}</p>
</div>
</div>
</div>
</div>
</div>
);
}
const searchTypeIcons = {
project: FolderKanban,
episode: BookOpen,
script: BookOpen,
shot: Clapperboard,
asset: Images,
task: ListChecks,
job: Wand2,
delivery: Download
};
const searchStatusLabels = {
production: "制作中",
draft: "草稿",
ready: "就绪",
open: "待处理",
in_progress: "进行中",
blocked: "阻塞",
done: "已完成",
queued: "排队中",
running: "运行中",
completed: "已完成",
failed: "失败",
published: "已发布",
submitted: "待审批",
approved: "已批准"
};
function GlobalSearchPalette({ open, onClose, contextOverrides, onNavigate }) {
const inputRef = useRef(null);
const [query, setQuery] = useState("");
const [scope, setScope] = useState("workspace");
const [selectedIndex, setSelectedIndex] = useState(0);
const [payload, setPayload] = useState({ results: [], total: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return undefined;
const normalized = query.trim();
if (!normalized) {
setPayload({ results: [], total: 0 });
setSelectedIndex(0);
setError("");
return undefined;
}
let cancelled = false;
const timer = setTimeout(async () => {
setLoading(true);
try {
const next = await searchPlatform(normalized, { scope, limit: 40 }, contextOverrides);
if (!cancelled) {
setPayload(next);
setSelectedIndex(0);
setError("");
}
} catch (requestError) {
if (!cancelled) {
setPayload({ results: [], total: 0 });
setError(requestError.message || "搜索失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}, 180);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [open, query, scope, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
useEffect(() => {
if (!open) return undefined;
function handleKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) => Math.min(current + 1, Math.max(0, payload.results.length - 1)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) => Math.max(0, current - 1));
} else if (event.key === "Enter" && payload.results[selectedIndex]) {
event.preventDefault();
onNavigate(payload.results[selectedIndex]);
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose, onNavigate, payload.results, selectedIndex]);
if (!open) return null;
const results = payload.results || [];
return (
<div className="global-search-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<section className="global-search-dialog" role="dialog" aria-modal="true" aria-label="全局搜索">
<div className="global-search-head">
<div className="global-search-input-wrap"><Search size={18} /><input ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、剧本、镜头、资产、任务…" aria-label="搜索项目、剧本、镜头、资产、任务" /></div>
<button className="icon-only-button" type="button" onClick={onClose} title="关闭搜索" aria-label="关闭搜索"><XCircle size={17} /></button>
</div>
<div className="global-search-toolbar">
<div className="global-search-scope" role="group" aria-label="搜索范围">
<button type="button" className={scope === "project" ? "selected" : ""} onClick={() => setScope("project")} disabled={!contextOverrides?.projectId}>当前项目</button>
<button type="button" className={scope === "workspace" ? "selected" : ""} onClick={() => setScope("workspace")}>当前工作区</button>
</div>
<span>{loading ? "搜索中…" : query.trim() ? `${payload.total || 0} 条结果` : "输入关键词开始搜索"}</span>
</div>
{error && <div className="global-search-error"><AlertTriangle size={15} />{error}</div>}
<div className="global-search-results">
{!query.trim() && <div className="global-search-empty"><Search size={24} /><strong>搜索整个生产工作区</strong><span>项目、剧本、分集、镜头、资产、任务、生成任务和交付记录都支持快速定位。</span></div>}
{query.trim() && !loading && !error && !results.length && <div className="global-search-empty"><Search size={24} /><strong>没有找到匹配内容</strong><span>试试项目名、镜头编号、角色名或任务关键词。</span></div>}
{results.map((result, index) => {
const Icon = searchTypeIcons[result.type] || Search;
return <button key={`${result.type}-${result.id}`} type="button" className={`global-search-result ${index === selectedIndex ? "selected" : ""}`} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { onNavigate(result); onClose(); }}>
<span className="global-search-result-icon"><Icon size={16} /></span>
<span className="global-search-result-copy"><strong>{result.title}</strong><small><b>{result.typeLabel}</b>{result.subtitle ? ` · ${result.subtitle}` : ""}</small></span>
{result.status && <span className="global-search-result-status">{searchStatusLabels[result.status] || result.status}</span>}
<ChevronRight size={15} />
</button>;
})}
</div>
<div className="global-search-foot"><span><kbd>↑</kbd><kbd>↓</kbd>选择</span><span><kbd>Enter</kbd>打开</span><span><kbd>Esc</kbd>关闭</span></div>
</section>
</div>
);
}
function App() {
const storedContext = getClientContext();
const storedAuthSession = getAuthSession();
const publicPortalToken = portalTokenFromLocation();
const [project, setProject] = useState(emptyProject);
const [platformContext, setPlatformContext] = useState(null);
const [authSession, setAuthSession] = useState(storedAuthSession);
const [authLoading, setAuthLoading] = useState(Boolean(storedAuthSession?.token));
const [authError, setAuthError] = useState("");
const [pendingAuthChallenge, setPendingAuthChallenge] = useState(null);
const [loginPending, setLoginPending] = useState(false);
const [contextLoading, setContextLoading] = useState(true);
const [contextError, setContextError] = useState("");
const [activeOrgId, setActiveOrgId] = useState(storedContext.organizationId || "org-studio-lab");
const [activeWorkspaceId, setActiveWorkspaceId] = useState(storedContext.workspaceId || "ws-local-aidrama");
const [activeProjectId, setActiveProjectId] = useState(storedContext.projectId || "");
const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || "");
const [activeTabState, setActiveTabState] = useState(tabFromLocation);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [activeShotId, setActiveShotId] = useState("");
const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter);
const [taskLog, setTaskLog] = useState([
"等待当前项目数据",
"单画面规则已启用",
"默认适配器:用户自有模型平台"
]);
function setActiveTab(nextTab) {
const next = navigationTabIds.has(nextTab) ? nextTab : "creator-home";
setActiveTabState(next);
setMobileNavOpen(false);
if (typeof window !== "undefined" && window.location.hash !== `#${next}`) {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${next}`);
}
}
useEffect(() => {
function handleHashChange() {
const next = tabFromLocation();
setActiveTabState(next);
setMobileNavOpen(false);
}
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, []);
useEffect(() => {
function handleGlobalShortcut(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
}
}
window.addEventListener("keydown", handleGlobalShortcut);
return () => window.removeEventListener("keydown", handleGlobalShortcut);
}, []);
const activeTab = activeTabState;
const adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]);
async function refreshContext(overrides = {}) {
const selection = {
userId: authSession?.user?.id || storedContext.userId || "u-owner",
organizationId: activeOrgId,
workspaceId: activeWorkspaceId,
projectId: activeProjectId,
...overrides
};
setContextLoading(true);
try {
const payload = await fetchPlatformContext(selection);
const current = payload.context;
setPlatformContext(payload);
setActiveOrgId(current.currentOrganization.id);
setActiveWorkspaceId(current.currentWorkspace.id);
setActiveProjectId(current.currentProject?.id || "");
const projectPayload = await fetchProject({
userId: current.currentUser.id,
organizationId: current.currentOrganization.id,
workspaceId: current.currentWorkspace.id,
projectId: current.currentProject?.id || ""
});
const nextProject = projectPayload.project || createProjectShell();
setProject(nextProject);
setActiveEpisodeId(nextProject.episode?.id || "");
setContextError("");
} catch (error) {
if (error.status === 401) {
setAuthSession(null);
setPlatformContext(null);
setAuthError("登录会话已失效,请重新登录");
}
setContextError(error.message);
} finally {
setContextLoading(false);
}
}
useEffect(() => {
let cancelled = false;
async function bootstrapAuth() {
if (publicPortalToken) {
setAuthLoading(false);
setContextLoading(false);
return;
}
const ssoTicket = ssoTicketFromLocation();
const ssoError = ssoErrorFromLocation();
if (ssoError) {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.hash || ""}`);
setAuthError(ssoError);
}
if (!storedAuthSession?.token) {
if (ssoTicket) {
try {
const payload = await redeemSsoTicket(ssoTicket);
if (cancelled) return;
window.history.replaceState(null, "", `${window.location.pathname}${window.location.hash || ""}`);
if (payload.mfaRequired) {
setPendingAuthChallenge(payload);
setAuthError("");
} else if (payload.session) {
setAuthSession({ ...payload.session, user: payload.user });
const current = payload.context;
await refreshContext({ userId: payload.user.id, organizationId: current.currentOrganization.id, workspaceId: current.currentWorkspace.id, projectId: current.currentProject?.id || "" });
}
} catch (error) {
if (!cancelled) setAuthError(error.message);
}
}
setAuthLoading(false);
setContextLoading(false);
return;
}
try {
const payload = await fetchAuthSession();
if (cancelled) return;
setAuthSession((current) => ({ ...current, user: payload.user, expiresAt: payload.session?.expiresAt }));
await refreshContext({
userId: payload.user.id,
organizationId: payload.context.currentOrganization.id,
workspaceId: payload.context.currentWorkspace.id,
projectId: payload.context.currentProject?.id || ""
});
} catch (error) {
if (cancelled) return;
setAuthSession(null);
setAuthError(error.status === 401 ? "登录会话已失效,请重新登录" : error.message);
} finally {
if (!cancelled) setAuthLoading(false);
}
}
bootstrapAuth();
return () => { cancelled = true; };
}, [publicPortalToken]);
async function handleLogin(credentials) {
setLoginPending(true);
setAuthError("");
try {
if (credentials.enrollmentAction === "setup") return await startMfaEnrollment(credentials.enrollmentToken);
const payload = credentials.enrollmentAction === "complete" ? await completeMfaEnrollment(credentials) : credentials.challengeToken ? await loginWithMfa(credentials) : await login(credentials);
if (payload.mfaRequired) return payload;
if (!payload.session) return payload;
setPendingAuthChallenge(null);
setAuthSession({ ...payload.session, user: payload.user });
const current = payload.context;
await refreshContext({
userId: payload.user.id,
organizationId: current.currentOrganization.id,
workspaceId: current.currentWorkspace.id,
projectId: current.currentProject?.id || ""
});
} catch (error) {
setAuthError(error.message);
} finally {
setLoginPending(false);
}
}
async function handleRegistration(payload) {
setAuthSession({ ...payload.session, user: payload.user });
const current = payload.context;
await refreshContext({
userId: payload.user.id,
organizationId: current.currentOrganization.id,
workspaceId: current.currentWorkspace.id,
projectId: current.currentProject?.id || ""
});
if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", `${window.location.pathname}#creator-home`);
setActiveTabState("creator-home");
}
async function handleLogout() {
await logout();
setAuthSession(null);
setPlatformContext(null);
setActiveTab("creator-home");
setContextError("");
setAuthError("");
}
useEffect(() => {
setActiveShotId(project.shots[0]?.id || "");
}, [project]);
useEffect(() => {
setAdapterId((current) => preferredAdapterId(adapterCatalog, current || project.production?.defaultAdapter));
}, [adapterCatalog, project.production?.defaultAdapter]);
function switchOrganization(organizationId) {
refreshContext({ organizationId, workspaceId: "", projectId: "" });
}
function switchWorkspace(workspaceId) {
refreshContext({ organizationId: activeOrgId, workspaceId, projectId: "" });
}
function switchProject(projectId) {
refreshContext({ organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId });
}
async function navigateFromSearch(result) {
const nextProjectId = result.projectId || (result.type === "project" ? result.id : activeProjectId);
await refreshContext({
organizationId: result.organizationId || activeOrgId,
workspaceId: result.workspaceId || activeWorkspaceId,
projectId: nextProjectId || ""
});
if (result.type === "shot" && typeof window !== "undefined") {
window.setTimeout(() => setActiveShotId(result.id), 0);
}
setActiveTab(result.targetTab || "creator-home");
}
async function inviteFromAdmin(body) {
const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
return result;
}
async function resendInvitationFromAdmin(invitationId) {
const result = await resendOrganizationInvitation(activeOrgId, invitationId, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
return result;
}
async function revokeInvitationFromAdmin(invitationId) {
const result = await revokeOrganizationInvitation(activeOrgId, invitationId, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
return result;
}
async function updateOrganizationMemberFromAdmin(userId, body) {
await updateOrganizationMember(activeOrgId, userId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateOrganizationRolePolicyFromAdmin(roleKey, body) {
await updateOrganizationRolePolicy(activeOrgId, roleKey, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateWorkspaceMemberFromAdmin(userId, body) {
await updateWorkspaceMember(activeWorkspaceId, userId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateProjectMemberFromAdmin(userId, body) {
await updateProjectMember(activeProjectId, userId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function addProjectMemberFromAdmin(userId, body) {
await addProjectMember(activeProjectId, { userId, ...body }, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateOrganizationFromAdmin(body) {
await updateOrganization(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateWorkspaceFromAdmin(body) {
await updateWorkspace(activeWorkspaceId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function updateProjectFromAdmin(body) {
await updateProject(activeProjectId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext();
}
async function changeProjectLifecycleFromFactory(projectId, action) {
const result = await changeProjectLifecycle(projectId, action, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId });
const nextProjectId = action === "archive" && projectId === activeProjectId ? "" : activeProjectId;
await refreshContext({ projectId: nextProjectId });
return result;
}
async function createWorkspaceFromAdmin(body) {
const result = await createWorkspace(body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext({ workspaceId: result.workspace.id, projectId: "" });
}
async function createOrganizationFromAdmin(body) {
const result = await createOrganization(body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext({ organizationId: result.organization.id, workspaceId: "", projectId: "" });
}
async function createProjectFromAdmin(body) {
const result = await createProject(body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext({ projectId: result.project.id });
}
function syncModelRegistry(models) {
setPlatformContext((current) => current ? { ...current, platform: { ...current.platform, modelRegistry: models, adapterCatalog: models } } : current);
}
async function handleInvitationAccepted(invitation) {
await refreshContext({
organizationId: invitation.organization_id,
workspaceId: invitation.workspace_id || "",
projectId: invitation.project_id || ""
});
}
const activeShot = project.shots.find((shot) => shot.id === activeShotId) || project.shots[0] || {
id: "",
title: "尚未创建镜头",
durationSec: 6,
characterIds: [],
propIds: [],
locationId: "",
firstFrame: "",
lastFrame: "",
transitionFromPrevious: "episode-start",
voiceLines: [],
prompt: "",
negativePrompt: "",
videoPrompt: "",
seed: 0
};
const selectedAdapter = adapterCatalog.find((item) => item.id === adapterId) || adapterCatalog[0] || normalizeAdapter(adapters[0]);
const qaResults = useMemo(() => projectQa(project), [project]);
const activeQa = activeShot.id ? qaResults.find((item) => item.shotId === activeShot.id) : null;
const exports = useMemo(() => buildAllExports(project), [project]);
const requestPreview = useMemo(
() => activeShot.id ? buildModelRequest(project, activeShot, selectedAdapter) : { schema: "ai-drama.job.v1", message: "请先在导演工作台创建至少一个镜头。" },
[project, activeShot, selectedAdapter]
);
async function enqueueJob(kind, shotId = activeShot.id, options = {}) {
const targetShot = project.shots.find((shot) => shot.id === shotId) || activeShot;
if (!targetShot?.id) throw new Error("当前项目还没有可生成的镜头,请先在导演工作台创建镜头");
const line = `${new Date().toLocaleTimeString("zh-CN", { hour12: false })} 已生成 ${targetShot.id} 的${kind}请求包,等待接入本地模型平台`;
try {
const result = await createJob({
kind,
shotId,
adapter: options.adapter || adapterId,
output: options.output,
priority: options.priority,
approveExternal: Boolean(options.approveExternal),
dependsOnJobIds: options.dependsOnJobIds || []
}, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
setProject((current) => ({ ...current, productionJobs: result.jobs || [result.job, ...(current.productionJobs || [])] }));
setTaskLog((items) => [line, ...items].slice(0, 8));
setContextError("");
return result;
} catch (error) {
setContextError(error.message);
setTaskLog((items) => [`请求未写入队列:${error.message}`, ...items].slice(0, 8));
throw error;
}
}
const qaPassCount = qaResults.flatMap((item) => item.gates).filter((gate) => gate.status === "pass").length;
const qaGateCount = qaResults.flatMap((item) => item.gates).length;
const contextPlatform = platformContext?.platform || {};
const organizations = contextPlatform.organizations || [];
const workspaces = contextPlatform.workspaces || [];
const projects = contextPlatform.projects || [];
const currentUser = platformContext?.context?.currentUser || authSession?.user;
const currentRole = platformContext?.context?.systemAdmin ? "系统管理员" : (platformContext?.context?.workspaceRole || platformContext?.context?.organizationRole || "local-admin");
const contextOverrides = {
userId: currentUser?.id || storedContext.userId || "u-owner",
organizationId: activeOrgId,
workspaceId: activeWorkspaceId,
projectId: activeProjectId,
episodeId: activeEpisodeId
};
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "policy:manage", "policy:read", "organization:manage", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
const canCreateProject = effectivePermissions.has("project:create");
const canCreateJob = effectivePermissions.has("job:create");
const canCreateJobNow = canCreateJob && project.status !== "archived";
const canManageProject = effectivePermissions.has("project:manage");
const canApproveDelivery = effectivePermissions.has("delivery:approve");
const canApproveVoice = effectivePermissions.has("voice:approve");
const canApproveAssets = effectivePermissions.has("compliance:manage");
const canSeeNavigationItem = (item) => {
if (item.requiredAnyPermissions) return item.requiredAnyPermissions.some((permission) => effectivePermissions.has(permission));
return !item.requiredPermission || effectivePermissions.has(item.requiredPermission);
};
const visibleNavigationGroups = navigationGroups
.filter((group) => group.label === "创作套件" || group.label === "账户" || group.label === "生产流水线" || (group.label === "管理员后台" && canViewAdmin) || (group.label === "系统设置" && canViewSystem))
.map((group) => ({ ...group, items: group.items.filter(canSeeNavigationItem) }))
.filter((group) => group.items.length);
const activeNavigationItem = visibleNavigationGroups.flatMap((group) => group.items).find((item) => item.id === activeTab);
useEffect(() => {
if (platformContext && !activeNavigationItem) setActiveTab("creator-home");
}, [platformContext, activeNavigationItem?.id]);
if (authLoading) {
return <div className="auth-loading"><RefreshCw size={18} />正在验证登录会话…</div>;
}
if (publicPortalToken) return <DeliveryPortalPage token={publicPortalToken} />;
if (!authSession?.token) {
const inviteToken = inviteTokenFromLocation();
return inviteToken ? <RegisterPage inviteToken={inviteToken} onRegistered={handleRegistration} /> : <LoginPage initialChallenge={pendingAuthChallenge} onLogin={handleLogin} loading={loginPending} error={authError} />;
}
return (
<div className={`app-shell ${mobileNavOpen ? "mobile-nav-open" : ""}`}>
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
<Film size={22} />
</div>
<div>
<h1>AI 短剧本地生产台</h1>
<p>{platformContext?.context?.currentOrganization?.name || project.series.title} / {project.episode.id}</p>
</div>
</div>
<nav className="nav enterprise-nav">
{visibleNavigationGroups.map((group) => (
<div className="nav-group" key={group.label}>
<span className="nav-group-label">{group.label}</span>
{group.items.map((item) => {
const Icon = item.icon;
return <button key={item.id} className={activeTab === item.id ? "selected" : ""} onClick={() => setActiveTab(item.id)} title={item.label}><Icon size={17} /><span>{item.label}</span></button>;
})}
</div>
))}
</nav>
<div className="template-list">
<h2>流程模板</h2>
{canCreateJobNow && workflowTemplates.map((template) => (
<button
key={template.id}
className={template.id === project.production.defaultTemplate ? "template active" : "template"}
onClick={() => setActiveTab("jobs")}
>
<span>{template.name}</span>
<StatusPill tone={template.status === "主流程" ? "ok" : "neutral"}>{template.status}</StatusPill>
</button>
))}
</div>
</aside>
{mobileNavOpen && <button className="mobile-nav-scrim" aria-label="关闭导航" onClick={() => setMobileNavOpen(false)} />}
<main className="workspace">
<header className="topbar">
<div className="topbar-heading">
<button className="mobile-menu-toggle" type="button" aria-label="打开导航" aria-expanded={mobileNavOpen} onClick={() => setMobileNavOpen(true)}><Menu size={18} /></button>
<div>
<p className="path">/Users/xz/Documents/daima/ai短剧/ai-drama-platform</p>
<h2>{activeNavigationItem?.label || (activeTab === "overview" ? "商用生产总控台" : project.episode.title)}</h2>
</div>
</div>
<div className="top-actions">
<div className="scope-switchers">
<label><Building2 size={14} /><span>组织</span><select value={activeOrgId} onChange={(event) => switchOrganization(event.target.value)} disabled={contextLoading || !organizations.length} aria-label="组织切换"><option value="">选择组织</option>{organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name}</option>)}</select></label>
<label><FolderKanban size={14} /><span>工作区</span><select value={activeWorkspaceId} onChange={(event) => switchWorkspace(event.target.value)} disabled={contextLoading || !workspaces.length} aria-label="工作区切换"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label>
<label><Film size={14} /><span>项目</span><select value={activeProjectId} onChange={(event) => switchProject(event.target.value)} disabled={contextLoading || !projects.length} aria-label="项目切换"><option value="">选择项目</option>{projects.map((projectItem) => <option key={projectItem.id} value={projectItem.id}>{projectItem.title || projectItem.name}</option>)}</select></label>
</div>
<button className="topbar-search-trigger" type="button" onClick={() => setSearchOpen(true)} title="打开全局搜索"><Search size={15} /><span>搜索生产内容</span><kbd>⌘K</kbd></button>
<NotificationBell contextOverrides={contextOverrides} onOpen={setActiveTab} />
<div className="user-context"><span className="user-avatar">{currentUser?.display_name?.slice(0, 1) || "林"}</span><div><strong>{currentUser?.display_name || "本地用户"}</strong><span>{currentRole}</span></div><button className="icon-only-button" title="账号与安全" aria-label="账号与安全" onClick={() => setActiveTab("account")}><KeyRound size={15} /></button><button className="icon-only-button" title="退出登录" aria-label="退出登录" onClick={handleLogout}><LogOut size={15} /></button></div>
{canCreateJobNow && <select value={adapterId} onChange={(event) => setAdapterId(event.target.value)} aria-label="生成适配器">
{adapterCatalog.map((adapter) => <option key={adapter.id} value={adapter.id}>{adapter.label} · {adapter.status === "ready" ? "就绪" : adapter.status === "optional" ? "可选" : adapter.status === "paused" ? "已暂停" : "未就绪"}</option>)}
</select>}
{canCreateJobNow && <button className="primary" onClick={() => enqueueJob("图生视频")}>
<Play size={16} />
<span>生成请求包</span>
</button>}
</div>
</header>
{contextError && <div className="context-alert"><AlertTriangle size={16} /><span>{contextError}</span><button className="subtle" onClick={() => refreshContext()}><RefreshCw size={14} />重试</button></div>}
{project.status === "archived" && <div className="project-lifecycle-alert"><Archive size={17} /><div><strong>当前项目已归档</strong><span>生产数据仍可查看和审计;生成、资产编辑、剧本改稿、QA 和交付写操作已锁定。</span></div>{canManageProject && <button className="subtle" onClick={() => setActiveTab("factory")}><FolderKanban size={14} />打开项目目录</button>}</div>}
{!enterpriseArea && <section className="metrics-row">
<Metric icon={Layers3} label="分镜" value={`${project.shots.length} 个`} />
<Metric icon={Mic2} label="对白" value={`${project.shots.flatMap((shot) => shot.voiceLines).length} 句`} />
<Metric icon={Gauge} label="QA" value={`${qaPassCount}/${qaGateCount}`} />
<Metric icon={TimerReset} label="批次" value={project.production.batchId} />
</section>}
<div className={`content-grid ${enterpriseArea ? "enterprise-content-grid" : ""}`}>
<section className="primary-panel">
{activeTab === "creator-home" && <CreatorHomePage project={project} platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} canCreateProject={canCreateProject} allowedTabs={visibleNavigationGroups.flatMap((group) => group.items).map((item) => item.id)} />}
{activeTab === "tasks" && <TaskCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "account" && <AccountSecurityPage authSession={authSession} platformContext={platformContext} contextOverrides={contextOverrides} onInvitationAccepted={handleInvitationAccepted} />}
{activeTab === "notifications" && <NotificationCenterPage contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
{activeTab === "assistant" && <ProjectAssistantPage project={project} setActiveTab={setActiveTab} allowedTabs={visibleNavigationGroups.flatMap((group) => group.items).map((item) => item.id)} />}
{activeTab === "knowledge" && <KnowledgeLibraryPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "asset-library" && <AssetLibraryPage project={project} contextOverrides={contextOverrides} canApproveAssets={canApproveAssets} />}
{activeTab === "voice-studio" && <VoiceStudioPage project={project} onQueueJob={enqueueJob} contextOverrides={contextOverrides} canApproveVoice={canApproveVoice} />}
{activeTab === "batch-production" && <BatchProductionPage project={project} contextOverrides={contextOverrides} onQueueJob={enqueueJob} />}
{activeTab === "overview" && (
<Cockpit
project={project}
qaResults={qaResults}
setActiveTab={setActiveTab}
setActiveShotId={setActiveShotId}
platformContext={platformContext}
canCreateJob={canCreateJobNow}
canApproveDelivery={canApproveDelivery}
/>
)}
{activeTab === "factory" && <ProjectFactoryPage platformContext={platformContext} contextOverrides={contextOverrides} canCreateProject={canCreateProject} canManageProject={canManageProject} onProjectCreated={createProjectFromAdmin} onSwitchProject={switchProject} onProjectLifecycle={changeProjectLifecycleFromFactory} />}
{activeTab === "script" && <ScriptProductionPage project={project} contextOverrides={contextOverrides} canEditAssets={effectivePermissions.has("asset:edit")} />}
{activeTab === "casting" && <CastingBoardPage contextOverrides={contextOverrides} />}
{activeTab === "director" && (
<DirectorProductionPage project={project} contextOverrides={contextOverrides} canCreateJob={canCreateJobNow} onQueueJob={enqueueJob} />
)}
{activeTab === "bible" && <SeriesBiblePage project={project} contextOverrides={contextOverrides} canEdit={effectivePermissions.has("script:edit")} onEpisodeChange={setActiveEpisodeId} />}
{activeTab === "locks" && <CastingBoardPage contextOverrides={contextOverrides} />}
{activeTab === "shots" && <DirectorProductionPage project={project} contextOverrides={contextOverrides} canCreateJob={canCreateJobNow} onQueueJob={enqueueJob} />}
{activeTab === "jobs" && <GenerationQueuePage project={project} contextOverrides={contextOverrides} canCreateJob={canCreateJobNow} canManageJobs={effectivePermissions.has("job:prioritize") || effectivePermissions.has("queue:manage")} canApproveExternal={effectivePermissions.has("model:manage")} adapters={adapterCatalog} adapterId={adapterId} setAdapterId={setAdapterId} activeShot={activeShot} requestPreview={requestPreview} onQueueJob={enqueueJob} setActiveShotId={setActiveShotId} />}
{activeTab === "modelops" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "qa" && <QaReviewPage contextOverrides={contextOverrides} />}
{activeTab === "compliance" && <ComplianceCenter platformContext={platformContext} />}
{activeTab === "export" && <DeliveryOpsPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />}
{activeTab === "delivery-portal" && <DeliveryAccessPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />}
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
{activeTab === "admin-audit" && <AdminAuditPage platformContext={platformContext} contextOverrides={contextOverrides} />}
{activeTab === "system-overview" && <SystemSettingsPage section="overview" contextOverrides={contextOverrides} />}
{activeTab === "system-identity" && <IdentityCenterPage contextOverrides={contextOverrides} />}
{activeTab === "system-users" && <SystemUsersPage contextOverrides={contextOverrides} />}
{activeTab === "system-generation" && <SystemSettingsPage section="generation" contextOverrides={contextOverrides} />}
{activeTab === "system-storage" && <SystemSettingsPage section="storage" contextOverrides={contextOverrides} />}
{activeTab === "system-notifications" && <SystemSettingsPage section="notifications" contextOverrides={contextOverrides} />}
{activeTab === "system-features" && <SystemSettingsPage section="features" contextOverrides={contextOverrides} />}
</section>
{!enterpriseArea && <aside className="inspector">
<SectionTitle icon={Settings2} title="镜头检查器" />
<div className="inspector-block">
<label>当前镜头</label>
<select value={activeShotId} onChange={(event) => setActiveShotId(event.target.value)}>
{project.shots.map((shot) => (
<option key={shot.id} value={shot.id}>
{shot.id} / {shot.title}
</option>
))}
</select>
</div>
<div className="inspector-block">
<label>Dialogue</label>
{activeShot.voiceLines.map((line) => {
const character = project.characters.find((item) => item.id === line.characterId);
return (
<div className="line-card" key={line.id}>
<strong>{character?.name}</strong>
<p>{line.text}</p>
<span>{line.mouthPlan} / {line.targetDurationSec}s</span>
</div>
);
})}
</div>
<div className="inspector-block">
<label>Continuity Ledger</label>
{project.ledger.map((item) => (
<div className="ledger-row" key={`${item.time}-${item.item}`}>
<strong>{item.time}</strong>
<span>{item.item}</span>
<p>{item.state}</p>
</div>
))}
</div>
</aside>}
</div>
</main>
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} contextOverrides={contextOverrides} onNavigate={navigateFromSearch} />
</div>
);
}
export default App;