1955 lines
106 KiB
React
1955 lines
106 KiB
React
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;
|