feat: expand commercial ai drama platform
This commit is contained in:
+18
-9
@@ -73,6 +73,7 @@ import {
|
||||
ProjectAssistantPage,
|
||||
VoiceStudioPage
|
||||
} from "./components/CreatorSuitePages";
|
||||
import { KnowledgeLibraryPage } from "./components/KnowledgeLibraryPage";
|
||||
import {
|
||||
CastingBoardPage,
|
||||
DeliveryAccessPage,
|
||||
@@ -110,7 +111,8 @@ const navigationGroups = [
|
||||
{ id: "creator-home", label: "我的工作台", icon: RadioTower },
|
||||
{ id: "tasks", label: "协作任务", icon: ListChecks, requiredPermission: "task:view" },
|
||||
{ id: "assistant", label: "项目 AI 助手", icon: Bot },
|
||||
{ id: "asset-library", label: "资产库", icon: Images, requiredPermission: "asset:edit" },
|
||||
{ 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" }
|
||||
]
|
||||
@@ -128,13 +130,13 @@ const navigationGroups = [
|
||||
{ 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, requiredPermission: "asset: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, requiredPermission: "asset:edit" },
|
||||
{ id: "locks", label: "连续性锁", icon: ShieldCheck, requiredAnyPermissions: ["asset:edit", "compliance:manage"] },
|
||||
{ id: "shots", label: "镜头清单", icon: Clapperboard, requiredPermission: "job:create" },
|
||||
{ id: "modelops", label: "模型中台", icon: Network, requiredPermission: "model:manage" },
|
||||
{ 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" },
|
||||
@@ -147,7 +149,7 @@ const navigationGroups = [
|
||||
{ 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-models", label: "模型与 Runner", icon: Network, requiredPermission: "model: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" }
|
||||
@@ -1706,9 +1708,9 @@ function App() {
|
||||
projectId: activeProjectId,
|
||||
episodeId: activeEpisodeId
|
||||
};
|
||||
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || 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 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", "usage:view", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
||||
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "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");
|
||||
@@ -1716,9 +1718,14 @@ function App() {
|
||||
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((item) => !item.requiredPermission || effectivePermissions.has(item.requiredPermission)) }))
|
||||
.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);
|
||||
|
||||
@@ -1830,7 +1837,9 @@ function App() {
|
||||
|
||||
{activeTab === "assistant" && <ProjectAssistantPage project={project} setActiveTab={setActiveTab} allowedTabs={visibleNavigationGroups.flatMap((group) => group.items).map((item) => item.id)} />}
|
||||
|
||||
{activeTab === "asset-library" && <AssetLibraryPage project={project} contextOverrides={contextOverrides} />}
|
||||
{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} />}
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ import {
|
||||
createAssetVersion,
|
||||
fetchAsset,
|
||||
fetchAssetContent,
|
||||
fetchAssetGovernanceReviews,
|
||||
fetchAssets,
|
||||
fetchJobs,
|
||||
queryAssistant,
|
||||
restoreAssetVersion,
|
||||
scanAssetGovernance,
|
||||
updateAssetLock,
|
||||
updateAssetRights,
|
||||
uploadAsset,
|
||||
@@ -82,6 +84,10 @@ function SuiteStatus({ status }) {
|
||||
review: ["待复核", "warn"],
|
||||
archived: ["已归档", "neutral"],
|
||||
"needs-evidence": ["待版权证据", "warn"],
|
||||
submitted: ["已提交", "warn"],
|
||||
rejected: ["已驳回", "warn"],
|
||||
expired: ["已过期", "warn"],
|
||||
clear: ["低风险", "ok"],
|
||||
completed: ["已完成", "ok"],
|
||||
approved: ["已确认", "ok"],
|
||||
"needs-user-approved-reference": ["待参考音频", "warn"]
|
||||
@@ -156,10 +162,18 @@ function normalizeAsset(row) {
|
||||
initial: metadata.initial || row.name?.slice(0, 1) || "资",
|
||||
subtitle: metadata.subtitle || "本地项目资产",
|
||||
usage: metadata.usage || "当前项目",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
tags: Array.isArray(currentVersion.tags) && currentVersion.tags.length ? currentVersion.tags : Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
detail: metadata.detail || "待补充资产描述",
|
||||
lock: metadata.lock || "待补充连续性备注",
|
||||
rightsStatus: row.currentVersion?.rights_status || "needs-evidence",
|
||||
provenance: currentVersion.provenance || metadata.provenance || {},
|
||||
risk: currentVersion.risk || metadata.risk || {},
|
||||
riskStatus: currentVersion.risk?.status || metadata.risk?.status || "review",
|
||||
riskScore: Number(currentVersion.risk?.score ?? metadata.risk?.score ?? 0),
|
||||
rightsEvidence: metadata.rightsEvidence || {},
|
||||
licenseScope: currentVersion.licenseScope || currentVersion.license_scope || "",
|
||||
expiresAt: currentVersion.expiresAt || currentVersion.expires_at || "",
|
||||
governanceReviews: row.governanceReviews || [],
|
||||
versions: row.versions || [],
|
||||
bindings: row.bindings || [],
|
||||
fileName: currentVersion.fileName || currentVersion.file_name || "",
|
||||
@@ -188,7 +202,144 @@ function inferAssetKind(file) {
|
||||
return "reference";
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const sourceTypeLabels = {
|
||||
original: "原创",
|
||||
commissioned: "委托创作",
|
||||
licensed: "授权素材",
|
||||
"user-upload": "用户导入",
|
||||
"generated-local": "本地模型生成",
|
||||
"public-domain": "公版/开放授权",
|
||||
"third-party-reference": "第三方参考"
|
||||
};
|
||||
|
||||
const licenseScopeLabels = {
|
||||
project: "仅当前项目",
|
||||
series: "当前系列",
|
||||
organization: "组织内复用",
|
||||
commercial: "商用发行",
|
||||
"review-only": "仅审核参考"
|
||||
};
|
||||
|
||||
function assetGovernanceDefaults(asset) {
|
||||
const provenance = asset?.provenance || {};
|
||||
const evidence = asset?.rightsEvidence || {};
|
||||
return {
|
||||
rightsStatus: asset?.rightsStatus || "needs-evidence",
|
||||
sourceType: provenance.sourceType || "original",
|
||||
sourceName: provenance.sourceName || asset?.name || "",
|
||||
sourceRef: provenance.sourceRef || "",
|
||||
evidenceReference: evidence.reference || evidence.consentRef || provenance.evidenceRef || "",
|
||||
licenseScope: asset?.licenseScope || "project",
|
||||
expiresAt: asset?.expiresAt ? asset.expiresAt.slice(0, 10) : "",
|
||||
notes: evidence.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function assetGovernancePayload(form, rightsStatus) {
|
||||
return {
|
||||
rightsStatus,
|
||||
provenance: {
|
||||
sourceType: form.sourceType,
|
||||
sourceName: form.sourceName,
|
||||
sourceRef: form.sourceRef,
|
||||
evidenceRef: form.evidenceReference
|
||||
},
|
||||
evidence: {
|
||||
reference: form.evidenceReference,
|
||||
source: "asset-library-governance",
|
||||
notes: form.notes
|
||||
},
|
||||
licenseScope: form.licenseScope,
|
||||
expiresAt: form.expiresAt || null,
|
||||
notes: form.notes
|
||||
};
|
||||
}
|
||||
|
||||
function AssetInspector({
|
||||
selected,
|
||||
busy,
|
||||
previewLoading,
|
||||
previewText,
|
||||
previewUrl,
|
||||
previewType,
|
||||
previewError,
|
||||
rightsForm,
|
||||
setRightsForm,
|
||||
reviewHistory,
|
||||
canApproveAssets,
|
||||
onScan,
|
||||
onRefreshReviews,
|
||||
onRightsDecision,
|
||||
onVerify,
|
||||
onDownload,
|
||||
onShowVersions,
|
||||
onShowBinding,
|
||||
onToggleLock
|
||||
}) {
|
||||
const risk = selected.risk || {};
|
||||
const issues = risk.issues || [];
|
||||
const evidencePresent = Boolean(String(rightsForm.evidenceReference || "").trim());
|
||||
const sourcePresent = Boolean(String(rightsForm.sourceType || rightsForm.sourceName || rightsForm.sourceRef || "").trim());
|
||||
const canApproveCurrent = canApproveAssets && evidencePresent && (selected.rawKind === "voice" || sourcePresent) && selected.riskStatus !== "blocked";
|
||||
const updateField = (key, value) => setRightsForm((current) => ({ ...current, [key]: value }));
|
||||
return (
|
||||
<aside className="asset-inspector commercial-asset-inspector">
|
||||
<div className="inspector-heading">
|
||||
<div><span className="card-kicker">ASSET GOVERNANCE</span><h3>{selected.name}</h3></div>
|
||||
<SuiteStatus status={selected.status} />
|
||||
</div>
|
||||
{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}
|
||||
{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}
|
||||
{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}
|
||||
{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={onVerify} disabled={busy}><Check size={13} />校验</button></div>}
|
||||
<dl className="suite-definition-list">
|
||||
<dt>类型</dt><dd>{selected.kind}</dd>
|
||||
<dt>版本</dt><dd>{selected.version}</dd>
|
||||
<dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd>
|
||||
<dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd>
|
||||
<dt>使用范围</dt><dd>{selected.usage}</dd>
|
||||
<dt>版权/授权</dt><dd><SuiteStatus status={selected.rightsStatus} /></dd>
|
||||
<dt>风险</dt><dd><SuiteStatus status={selected.riskStatus} /></dd>
|
||||
<dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd>
|
||||
</dl>
|
||||
<div className={`asset-risk-panel ${selected.riskStatus}`}>
|
||||
<div><CircleAlert size={15} /><strong>风险分 {selected.riskScore || risk.score || 0}</strong><SuiteStatus status={selected.riskStatus} /></div>
|
||||
{issues.length ? <div className="asset-risk-list">{issues.map((issue) => <div key={`${issue.code}-${issue.label}`}><span>{issue.severity}</span><strong>{issue.label}</strong><p>{issue.message}</p></div>)}</div> : <p>当前版本未发现阻断项。</p>}
|
||||
</div>
|
||||
<form className="asset-governance-form" onSubmit={(event) => { event.preventDefault(); onRightsDecision("submitted"); }}>
|
||||
<div className="governance-form-grid">
|
||||
<label>来源类型<select value={rightsForm.sourceType} onChange={(event) => updateField("sourceType", event.target.value)}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={rightsForm.licenseScope} onChange={(event) => updateField("licenseScope", event.target.value)}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={rightsForm.sourceName} onChange={(event) => updateField("sourceName", event.target.value)} placeholder="原创角色设定、委托合同或素材包名" /></label>
|
||||
<label>来源引用<input value={rightsForm.sourceRef} onChange={(event) => updateField("sourceRef", event.target.value)} placeholder="本地路径、合同编号或导入批次" /></label>
|
||||
<label className="full-span">授权证据<input value={rightsForm.evidenceReference} onChange={(event) => updateField("evidenceReference", event.target.value)} placeholder="授权书、合同、同意书或本地证据路径" /></label>
|
||||
<label>到期日期<input type="date" value={rightsForm.expiresAt} onChange={(event) => updateField("expiresAt", event.target.value)} /></label>
|
||||
<label className="full-span">审核备注<textarea value={rightsForm.notes} onChange={(event) => updateField("notes", event.target.value)} rows="3" placeholder="授权边界、禁止事项、复用限制" /></label>
|
||||
</div>
|
||||
<div className="governance-action-row">
|
||||
<button type="button" className="subtle" onClick={onScan} disabled={busy}><ShieldCheck size={15} />风险扫描</button>
|
||||
<button type="submit" className="subtle" disabled={busy || !evidencePresent}><FileText size={15} />提交证据</button>
|
||||
<button type="button" className="primary" onClick={() => onRightsDecision("approved")} disabled={busy || !canApproveCurrent}><CheckCircle2 size={15} />批准授权</button>
|
||||
<button type="button" className="danger-button" onClick={() => onRightsDecision("rejected")} disabled={busy}><XCircle size={15} />驳回</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div>
|
||||
<div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div>
|
||||
<div className="asset-review-history">
|
||||
<div className="asset-review-head"><strong>审核记录</strong><button className="icon-text-button" onClick={onRefreshReviews} disabled={busy}><RefreshCw size={13} />刷新</button></div>
|
||||
{reviewHistory.length ? reviewHistory.slice(0, 5).map((review) => <div key={review.id} className="asset-review-row"><div><strong>{review.reviewerName || review.reviewer_user_id}</strong><span>{review.created_at?.replace("T", " ").slice(0, 19)}</span></div><SuiteStatus status={review.rights_status} /><small>{review.notes || review.evidence_ref || "已记录治理动作"}</small></div>) : <div className="asset-review-empty">暂无审核记录</div>}
|
||||
</div>
|
||||
<div className="inspector-actions">
|
||||
<button className="subtle" onClick={onShowVersions}><RefreshCw size={15} />版本记录</button>
|
||||
<button className="subtle" onClick={onDownload} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button>
|
||||
<button className="primary" onClick={onShowBinding}><LockKeyhole size={15} />绑定到镜头</button>
|
||||
<button className="subtle" onClick={onToggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides, canApproveAssets = false }) {
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [query, setQuery] = useState("");
|
||||
const [assets, setAssets] = useState([]);
|
||||
@@ -206,8 +357,10 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showBinding, setShowBinding] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "original", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "user-upload", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
const [rightsForm, setRightsForm] = useState(assetGovernanceDefaults(null));
|
||||
const [reviewHistory, setReviewHistory] = useState([]);
|
||||
const [pendingFile, setPendingFile] = useState(null);
|
||||
const [bindingForm, setBindingForm] = useState({ shotId: project.shots[0]?.id || "", usageRole: "continuity" });
|
||||
const inputRef = useRef(null);
|
||||
@@ -233,6 +386,11 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
|
||||
useEffect(() => { loadAssets(); }, [project.series?.id, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setRightsForm(assetGovernanceDefaults(selected));
|
||||
setReviewHistory(selected?.governanceReviews || []);
|
||||
}, [selected?.id, selected?.currentVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl = "";
|
||||
@@ -242,7 +400,8 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
setPreviewText("");
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
if (!selected?.id || !selected.currentVersion?.storage_path || selected.currentVersion.storage_path.endsWith("metadata.json")) return;
|
||||
const storagePath = selected?.currentVersion?.storage_path || "";
|
||||
if (!selected?.id || !storagePath || storagePath.endsWith("metadata.json") || !storagePath.startsWith("storage/")) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
@@ -280,7 +439,7 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const checked = validateAssetFile(file);
|
||||
const name = checked.name.replace(/\.[^.]+$/, "") || checked.name;
|
||||
setPendingFile(checked);
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "user-upload", sourceName: checked.name, sourceRef: `local-upload/${checked.name}`, licenseScope: "project" });
|
||||
setShowUpload(true);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
@@ -316,7 +475,9 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
lock: uploadForm.lock,
|
||||
mimeType: pendingFile.type || "application/octet-stream",
|
||||
rightsStatus: uploadForm.rightsStatus,
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
provenance: { sourceType: uploadForm.sourceType, sourceName: uploadForm.sourceName || pendingFile.name, sourceRef: uploadForm.sourceRef },
|
||||
licenseScope: uploadForm.licenseScope
|
||||
}, contextOverrides);
|
||||
replaceAsset(asset.asset);
|
||||
setShowUpload(false);
|
||||
@@ -334,10 +495,16 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
if (!assetForm.name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAsset({ ...assetForm, name: assetForm.name.trim(), tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean) }, contextOverrides);
|
||||
const payload = await createAsset({
|
||||
...assetForm,
|
||||
name: assetForm.name.trim(),
|
||||
tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
provenance: { sourceType: assetForm.sourceType, sourceName: assetForm.sourceName || assetForm.name.trim(), sourceRef: assetForm.sourceRef },
|
||||
licenseScope: assetForm.licenseScope
|
||||
}, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowCreate(false);
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "original", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
setNotice(`资产“${payload.asset.name}”已创建为 v1 草稿。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
@@ -468,6 +635,50 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGovernanceReviews() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchAssetGovernanceReviews(selected.id, contextOverrides);
|
||||
setReviewHistory(payload.reviews || []);
|
||||
setNotice(`${selected.name} 的审核记录已刷新。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSelectedGovernance() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await scanAssetGovernance(selected.id, assetGovernancePayload(rightsForm, rightsForm.rightsStatus), contextOverrides);
|
||||
const next = replaceAsset(payload.asset);
|
||||
setReviewHistory(next.governanceReviews || []);
|
||||
setNotice(`${selected.name} 风险扫描完成:${payload.scan.status === "blocked" ? "存在阻断项" : payload.scan.status === "review" ? "需要复核" : "低风险"},评分 ${payload.scan.score}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSelectedRights(rightsStatus) {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await updateAssetRights(selected.id, assetGovernancePayload(rightsForm, rightsStatus), contextOverrides);
|
||||
const next = replaceAsset(payload.asset);
|
||||
setReviewHistory(next.governanceReviews || []);
|
||||
setNotice(`${selected.name} 授权状态已更新为 ${rightsStatus === "approved" ? "已批准" : rightsStatus === "submitted" ? "已提交" : rightsStatus === "rejected" ? "已驳回" : rightsStatus}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader
|
||||
@@ -499,14 +710,60 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
<button className="icon-text-button" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={14} />选择文件</button>
|
||||
</div>
|
||||
<div className="asset-card-grid">
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><SuiteStatus status={asset.status} /></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><span className="asset-card-statuses"><SuiteStatus status={asset.status} /><SuiteStatus status={asset.rightsStatus} /><SuiteStatus status={asset.riskStatus} /></span></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
</div>
|
||||
{!filtered.length && <div className="suite-empty"><Search size={18} />{loading ? "正在读取资产库…" : "没有匹配资产"}</div>}
|
||||
</section>
|
||||
{selected && <aside className="asset-inspector"><div className="inspector-heading"><div><span className="card-kicker">ASSET INSPECTOR</span><h3>{selected.name}</h3></div><SuiteStatus status={selected.status} /></div>{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={verifySelected} disabled={busy}><Check size={13} />校验</button></div>}<dl className="suite-definition-list"><dt>类型</dt><dd>{selected.kind}</dd><dt>版本</dt><dd>{selected.version}</dd><dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd><dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd><dt>使用范围</dt><dd>{selected.usage}</dd><dt>版权/授权</dt><dd>{selected.rightsStatus === "approved" ? "已确认" : "待补证据"}</dd><dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd></dl><div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div><div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div><div className="inspector-actions"><button className="subtle" onClick={() => setShowVersions(true)}><RefreshCw size={15} />版本记录</button><button className="subtle" onClick={downloadSelected} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button><button className="primary" onClick={() => setShowBinding(true)}><LockKeyhole size={15} />绑定到镜头</button><button className="subtle" onClick={toggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button></div></aside>}
|
||||
{selected && <AssetInspector selected={selected} busy={busy} previewLoading={previewLoading} previewText={previewText} previewUrl={previewUrl} previewType={previewType} previewError={previewError} rightsForm={rightsForm} setRightsForm={setRightsForm} reviewHistory={reviewHistory} canApproveAssets={canApproveAssets} onScan={scanSelectedGovernance} onRefreshReviews={loadGovernanceReviews} onRightsDecision={updateSelectedRights} onVerify={verifySelected} onDownload={downloadSelected} onShowVersions={() => setShowVersions(true)} onShowBinding={() => setShowBinding(true)} onToggleLock={toggleLock} />}
|
||||
</div>
|
||||
{showUpload && pendingFile && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}><form className="suite-modal" onSubmit={submitUpload}><div className="suite-modal-head"><div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div><button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button></div><div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div><div className="suite-form-grid"><label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label><label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label><label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label><label>标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label><label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label><label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div></form></div>}
|
||||
{showCreate && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}><form className="suite-modal" onSubmit={submitCreate}><div className="suite-modal-head"><div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div><button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="suite-form-grid"><label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label><label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label><label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label><label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label><label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div></form></div>}
|
||||
{showUpload && pendingFile && (
|
||||
<div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}>
|
||||
<form className="suite-modal" onSubmit={submitUpload}>
|
||||
<div className="suite-modal-head">
|
||||
<div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div>
|
||||
<button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button>
|
||||
</div>
|
||||
<div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div>
|
||||
<div className="suite-form-grid">
|
||||
<label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label>
|
||||
<label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label>
|
||||
<label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={uploadForm.sourceType} onChange={(event) => setUploadForm((current) => ({ ...current, sourceType: event.target.value }))}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={uploadForm.licenseScope} onChange={(event) => setUploadForm((current) => ({ ...current, licenseScope: event.target.value }))}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源引用<input value={uploadForm.sourceRef} onChange={(event) => setUploadForm((current) => ({ ...current, sourceRef: event.target.value }))} /></label>
|
||||
<label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label>
|
||||
<label className="full-span">标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label>
|
||||
<label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label>
|
||||
<label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label>
|
||||
</div>
|
||||
<div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showCreate && (
|
||||
<div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}>
|
||||
<form className="suite-modal" onSubmit={submitCreate}>
|
||||
<div className="suite-modal-head">
|
||||
<div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div>
|
||||
<button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button>
|
||||
</div>
|
||||
<div className="suite-form-grid">
|
||||
<label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label>
|
||||
<label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label>
|
||||
<label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={assetForm.sourceType} onChange={(event) => setAssetForm((current) => ({ ...current, sourceType: event.target.value }))}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={assetForm.licenseScope} onChange={(event) => setAssetForm((current) => ({ ...current, licenseScope: event.target.value }))}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label className="full-span">来源引用<input value={assetForm.sourceRef} onChange={(event) => setAssetForm((current) => ({ ...current, sourceRef: event.target.value }))} placeholder="原创设定文档、合同编号或本地证据路径" /></label>
|
||||
<label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label>
|
||||
<label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label>
|
||||
<label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label>
|
||||
</div>
|
||||
<div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showVersions && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowVersions(false)}><section className="suite-modal"><div className="suite-modal-head"><div><span className="card-kicker">VERSION HISTORY</span><h3>{selected.name}</h3></div><button className="icon-only-button" onClick={() => setShowVersions(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="version-list">{selected.versions.map((version) => <div key={version.id}><div><strong>v{version.version_number}{selected.currentVersionId === version.id ? " · 当前" : ""}</strong><span>{version.metadata?.versionNote || "版本记录"}</span></div><SuiteStatus status={version.rights_status === "approved" ? "approved" : "needs-evidence"} /><small>{version.storage_path}{version.content_sha256 ? ` · SHA ${version.content_sha256.slice(0, 12)}…` : ""}</small>{selected.currentVersionId !== version.id && <button className="icon-text-button" onClick={() => restoreVersion(version)} disabled={busy}><RefreshCw size={13} />恢复此版本</button>}</div>)}</div><div className="suite-modal-actions"><button className="subtle" onClick={() => setShowVersions(false)}>关闭</button><button className="subtle" onClick={() => versionInputRef.current?.click()} disabled={busy}><Upload size={15} />上传新版本</button><button className="primary" onClick={createVersion} disabled={busy}><Plus size={15} />创建空白版本</button></div></section></div>}
|
||||
{showBinding && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowBinding(false)}><form className="suite-modal compact" onSubmit={bindSelected}><div className="suite-modal-head"><div><span className="card-kicker">SHOT BINDING</span><h3>绑定到镜头</h3></div><button type="button" className="icon-only-button" onClick={() => setShowBinding(false)} aria-label="关闭"><XCircle size={17} /></button></div><p className="suite-modal-copy">绑定后,生成任务会把“{selected.name}”的当前版本作为连续性输入。</p><div className="suite-form-grid"><label>镜头<select value={bindingForm.shotId} onChange={(event) => setBindingForm((current) => ({ ...current, shotId: event.target.value }))}>{project.shots.map((shot) => <option key={shot.id} value={shot.id}>{shot.id} · {shot.title}</option>)}</select></label><label>使用角色<select value={bindingForm.usageRole} onChange={(event) => setBindingForm((current) => ({ ...current, usageRole: event.target.value }))}><option value="character">角色</option><option value="location">场景</option><option value="prop">道具</option><option value="style">风格</option><option value="continuity">连续性</option></select></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowBinding(false)}>取消</button><button type="submit" className="primary" disabled={busy}><LockKeyhole size={15} />确认绑定</button></div></form></div>}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LogIn,
|
||||
ListFilter,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -70,6 +71,16 @@ import {
|
||||
createSystemBackup,
|
||||
probeModel,
|
||||
registerModel,
|
||||
fetchModelCatalog,
|
||||
createModelCatalog,
|
||||
updateModelCatalog,
|
||||
fetchModelRoutes,
|
||||
createModelRoute,
|
||||
resolveModelRoute,
|
||||
fetchModelRouteApprovals,
|
||||
requestModelRouteApproval,
|
||||
decideModelRouteApproval,
|
||||
updateModelRoute,
|
||||
retryJob,
|
||||
revokeAuthSession,
|
||||
revokeOtherAuthSessions,
|
||||
@@ -85,6 +96,8 @@ import {
|
||||
saveSystemConfig,
|
||||
startMfaSetup,
|
||||
updateFeatureFlag,
|
||||
createSystemPlanTemplate,
|
||||
updateSystemPlanTemplate,
|
||||
updateNotificationChannel,
|
||||
previewInvitation,
|
||||
registerInvitedUser,
|
||||
@@ -96,6 +109,8 @@ import {
|
||||
fetchSsoProviders,
|
||||
fetchIdentityCenter,
|
||||
fetchOrganizationCommercial,
|
||||
createCommercialApproval,
|
||||
decideCommercialApproval,
|
||||
fetchOrganizationUsage,
|
||||
fetchOrganizationInvoices,
|
||||
exportOrganizationCommercial,
|
||||
@@ -109,6 +124,7 @@ import {
|
||||
resetSystemUserPassword,
|
||||
revokeSystemUserSessions,
|
||||
updateOrganizationBilling,
|
||||
updateOrganizationEntitlement,
|
||||
generateOrganizationInvoice,
|
||||
updateOrganizationInvoiceStatus,
|
||||
updateCostCenter,
|
||||
@@ -146,8 +162,8 @@ import {
|
||||
import { downloadJson } from "../lib/exporters";
|
||||
|
||||
function toneForStatus(status) {
|
||||
if (["ready", "active", "completed", "pass", "enforced"].includes(status)) return "ok";
|
||||
if (["failed", "blocked", "error", "disabled"].includes(status)) return "warn";
|
||||
if (["ready", "active", "completed", "pass", "enforced", "approved"].includes(status)) return "ok";
|
||||
if (["failed", "blocked", "error", "disabled", "rejected", "expired", "cancelled"].includes(status)) return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
@@ -177,6 +193,13 @@ function SectionBar({ title, detail, action }) {
|
||||
return <div className="section-bar"><div><h3>{title}</h3>{detail && <span>{detail}</span>}</div>{action}</div>;
|
||||
}
|
||||
|
||||
function formatMetricValue(value, digits = 2) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number)) return String(value ?? "");
|
||||
if (Number.isInteger(number)) return String(number);
|
||||
return number.toFixed(digits).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function ContextLink({ children, onClick }) {
|
||||
return <button className="context-link" onClick={onClick}>{children}<ArrowUpRight size={14} /></button>;
|
||||
}
|
||||
@@ -240,6 +263,36 @@ const taskPriorityLabels = {
|
||||
low: "低优先级"
|
||||
};
|
||||
|
||||
const commercialApprovalTypeLabels = {
|
||||
entitlement_overage: "套餐超额",
|
||||
feature_enablement: "功能开通",
|
||||
budget_increase: "预算提升",
|
||||
external_connector: "外部连接器",
|
||||
compliance_review: "法务复核",
|
||||
delivery_exception: "交付例外",
|
||||
storage_retention: "留存策略"
|
||||
};
|
||||
|
||||
const commercialApprovalStatusLabels = {
|
||||
submitted: "待审批",
|
||||
approved: "已通过",
|
||||
rejected: "已拒绝",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
const commercialApprovalPriorityLabels = {
|
||||
urgent: "紧急",
|
||||
high: "高",
|
||||
medium: "普通",
|
||||
low: "低"
|
||||
};
|
||||
|
||||
function approvalStatusTone(status) {
|
||||
if (status === "approved") return "active";
|
||||
if (status === "rejected" || status === "cancelled") return "failed";
|
||||
return "needs-evidence";
|
||||
}
|
||||
|
||||
function formatTaskDue(value, overdue = false) {
|
||||
if (!value) return "无截止时间";
|
||||
const date = new Date(value);
|
||||
@@ -1226,7 +1279,9 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canManagePlan = permissions.has("billing:manage");
|
||||
const canManageQuota = permissions.has("quota:manage");
|
||||
const [data, setData] = useState({ billing: null, seat: {}, quotas: [], usage: { usage: [] } });
|
||||
const canManageEntitlements = canManageQuota;
|
||||
const canReviewCommercialApprovals = Boolean(platformContext?.context?.systemAdmin || ["billing:manage", "quota:manage", "model:approve", "compliance:manage", "delivery:approve"].some((permission) => permissions.has(permission)));
|
||||
const [data, setData] = useState({ billing: null, seat: {}, quotas: [], usage: { usage: [] }, entitlements: [], entitlementSummary: {}, planTemplates: [], commercialApprovals: [], commercialApprovalSummary: {}, commercialApprovalCatalog: [], commercialApprovalReviewTypes: [] });
|
||||
const [usageData, setUsageData] = useState({ items: [], summary: { eventCount: 0, totalUnits: 0, totalCost: 0, byKind: [] }, pagination: { page: 1, pageSize: 25, total: 0, totalPages: 0 }, facets: { workspaces: [], projects: [], users: [], kinds: [], costCenters: [] } });
|
||||
const [invoiceData, setInvoiceData] = useState({ invoices: [], summary: { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 }, pagination: { page: 1, pageSize: 8, total: 0, totalPages: 0 } });
|
||||
const [invoiceFilters, setInvoiceFilters] = useState({ status: "", query: "", page: 1, pageSize: 8 });
|
||||
@@ -1238,7 +1293,10 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
});
|
||||
const [planForm, setPlanForm] = useState({ planName: "", billingCycle: "monthly", currency: "CNY", baseFee: 0, seatUnitPrice: 0, storageUnitPrice: 0, clipUnitPrice: 0, seatLimit: 0, storageGb: 0, monthlyClipQuota: 0, quotaWarningPercent: 80, localRunnerOnly: true, cloudConnectorsRequireApproval: true });
|
||||
const [quotaDrafts, setQuotaDrafts] = useState({});
|
||||
const [entitlementDrafts, setEntitlementDrafts] = useState({});
|
||||
const [costCenterDrafts, setCostCenterDrafts] = useState({});
|
||||
const [approvalDraft, setApprovalDraft] = useState({ requestType: "entitlement_overage", targetKey: "limit.generation_jobs_monthly", requestedValue: 2900, priority: "high", title: "", businessReason: "本地 Runner 试制批次需要追加镜头生成额度,仍遵守单画面、连续性台账和固定声线规则。", evidenceRef: "" });
|
||||
const [approvalDecisionNotes, setApprovalDecisionNotes] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [busy, setBusy] = useState("");
|
||||
@@ -1295,6 +1353,7 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
cloudConnectorsRequireApproval: Boolean(result.billing?.cloud_connectors_require_approval)
|
||||
});
|
||||
setQuotaDrafts(Object.fromEntries((result.quotas || []).map((quota) => [quota.id, quota.limitValue])));
|
||||
setEntitlementDrafts(Object.fromEntries((result.entitlements || []).map((entitlement) => [entitlement.key, { limitValue: entitlement.limitValue, enabled: entitlement.enabled, enforcement: entitlement.enforcement, overrideReason: entitlement.overrideReason || "" }])));
|
||||
setCostCenterDrafts(Object.fromEntries((result.costCenters || []).map((center) => [center.id, center.monthly_budget])));
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
@@ -1409,6 +1468,78 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEntitlementDraft(key, patch) {
|
||||
setEntitlementDrafts((current) => ({ ...current, [key]: { ...(current[key] || {}), ...patch } }));
|
||||
}
|
||||
|
||||
async function saveEntitlement(entitlement) {
|
||||
if (!canManageEntitlements) return;
|
||||
const draft = entitlementDrafts[entitlement.key] || {};
|
||||
setBusy(`entitlement-${entitlement.key}`);
|
||||
try {
|
||||
const result = await updateOrganizationEntitlement(organizationId, entitlement.key, {
|
||||
limitValue: Number(draft.limitValue ?? entitlement.limitValue),
|
||||
enabled: draft.enabled ?? entitlement.enabled,
|
||||
enforcement: draft.enforcement || entitlement.enforcement,
|
||||
overrideReason: draft.overrideReason || "管理员手动调整套餐权益"
|
||||
}, contextOverrides);
|
||||
setData(result);
|
||||
setEntitlementDrafts(Object.fromEntries((result.entitlements || []).map((item) => [item.key, { limitValue: item.limitValue, enabled: item.enabled, enforcement: item.enforcement, overrideReason: item.overrideReason || "" }])));
|
||||
setNotice(`${entitlement.label} 权益已更新,并写入审计日志`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCommercialApproval(event) {
|
||||
event.preventDefault();
|
||||
if (!organizationId) return;
|
||||
setBusy("commercial-approval-submit");
|
||||
try {
|
||||
await createCommercialApproval(organizationId, {
|
||||
requestType: approvalDraft.requestType,
|
||||
targetKey: approvalDraft.targetKey,
|
||||
requestedValue: Number(approvalDraft.requestedValue || 0),
|
||||
priority: approvalDraft.priority,
|
||||
title: approvalDraft.title,
|
||||
businessReason: approvalDraft.businessReason,
|
||||
evidence: approvalDraft.evidenceRef ? { evidenceRef: approvalDraft.evidenceRef } : {}
|
||||
}, contextOverrides);
|
||||
await load();
|
||||
setNotice("商业治理申请已提交,管理员审批前不会自动放开额度或外部能力");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function decideCommercialRequest(request, decision) {
|
||||
if (!request?.id) return;
|
||||
setBusy(`commercial-approval-${request.id}-${decision}`);
|
||||
try {
|
||||
const result = await decideCommercialApproval(organizationId, request.id, {
|
||||
decision,
|
||||
decisionNote: approvalDecisionNotes[request.id] || (decision === "approved" ? "审批通过,按申请自动同步组织治理策略。" : "审批拒绝,保留原因供申请人调整后重提。")
|
||||
}, contextOverrides);
|
||||
if (result.commercial) {
|
||||
setData(result.commercial);
|
||||
setQuotaDrafts(Object.fromEntries((result.commercial.quotas || []).map((item) => [item.id, item.limitValue])));
|
||||
setEntitlementDrafts(Object.fromEntries((result.commercial.entitlements || []).map((item) => [item.key, { limitValue: item.limitValue, enabled: item.enabled, enforcement: item.enforcement, overrideReason: item.overrideReason || "" }])));
|
||||
setCostCenterDrafts(Object.fromEntries((result.commercial.costCenters || []).map((item) => [item.id, item.monthly_budget])));
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
setNotice(decision === "approved" ? "申请已通过,自动生效项已写入后台策略和审计日志" : decision === "cancelled" ? "申请已取消" : "申请已拒绝并保留审批记录");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCostCenter(center) {
|
||||
if (!canManagePlan) return;
|
||||
setBusy(center.id);
|
||||
@@ -1505,6 +1636,35 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
const invoicePageSize = Number(invoicePagination.pageSize || 8);
|
||||
const invoiceStart = invoiceTotal ? (invoicePage - 1) * invoicePageSize + 1 : 0;
|
||||
const invoiceEnd = Math.min(invoicePage * invoicePageSize, invoiceTotal);
|
||||
const entitlementSummary = data.entitlementSummary || {};
|
||||
const planTemplates = data.planTemplates || [];
|
||||
const entitlements = data.entitlements || [];
|
||||
const entitlementCategories = [
|
||||
["tenant", "租户与组织"],
|
||||
["production", "生产流水线"],
|
||||
["knowledge", "知识库"],
|
||||
["modelops", "模型中台"],
|
||||
["delivery", "交付"],
|
||||
["storage", "存储"],
|
||||
["system", "系统集成"],
|
||||
["feature", "功能开关"]
|
||||
].filter(([key]) => entitlements.some((entitlement) => entitlement.category === key));
|
||||
const commercialApprovalSummary = data.commercialApprovalSummary || {};
|
||||
const commercialApprovals = data.commercialApprovals || [];
|
||||
const commercialApprovalCatalog = data.commercialApprovalCatalog || [];
|
||||
function targetsForApprovalType(requestType) {
|
||||
if (requestType === "budget_increase") return (data.costCenters || []).map((center) => ({ value: center.code, label: `${center.name} · 当前 ${formatMetricValue(center.monthly_budget)} ${center.currency}`, suggested: Number(center.monthly_budget || 0) + 100, current: Number(center.monthly_budget || 0) }));
|
||||
if (requestType === "feature_enablement" || requestType === "external_connector") return entitlements.filter((item) => item.key.startsWith("feature.")).map((item) => ({ value: item.key, label: `${item.label} · ${item.enabled ? "已开通" : "未开通"}`, suggested: 1, current: item.enabled ? 1 : 0 }));
|
||||
if (requestType === "compliance_review") return [{ value: "commercial-rights-review", label: "商用版权 / 原创素材复核", suggested: 1, current: 0 }, { value: "voice-consent-review", label: "固定声线授权复核", suggested: 1, current: 0 }, { value: "single-frame-contract-review", label: "单画面生成契约复核", suggested: 1, current: 0 }];
|
||||
return entitlements.filter((item) => !item.key.startsWith("feature.")).map((item) => ({ value: item.key, label: `${item.label} · 当前 ${formatMetricValue(item.limitValue)} ${item.unit}`, suggested: Number(item.limitValue || 0) + Math.max(1, Math.ceil(Number(item.limitValue || 0) * 0.2)), current: Number(item.limitValue || 0) }));
|
||||
}
|
||||
const commercialApprovalTargets = targetsForApprovalType(approvalDraft.requestType);
|
||||
const currentApprovalTarget = commercialApprovalTargets.find((item) => item.value === approvalDraft.targetKey) || commercialApprovalTargets[0] || null;
|
||||
const currentUserId = platformContext?.context?.currentUser?.id;
|
||||
function changeApprovalType(requestType) {
|
||||
const targets = targetsForApprovalType(requestType);
|
||||
setApprovalDraft((current) => ({ ...current, requestType, targetKey: targets[0]?.value || "", requestedValue: targets[0]?.suggested ?? current.requestedValue }));
|
||||
}
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / BILLING & QUOTAS" title="用量、席位与配额" description="组织管理员在这里管理席位预留、账单周期、配额预警、成本中心和本地执行策略。所有限制都在服务端执行。" action={<div className="header-actions"><button className="subtle" onClick={exportCommercial}><Download size={15} />导出运营数据</button><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新运营数据</button></div>} />
|
||||
@@ -1513,14 +1673,49 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
<div className="admin-kpi-grid">
|
||||
<AdminKpi icon={Users} label="席位" value={`${seat.active || 0}/${seat.limit || 0}`} detail={`${seat.pending || 0} 个邀请已预留`} tone={seat.remaining <= 0 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Gauge} label="月度片段" value={`${clipQuota}/${clipLimit}`} detail="组织套餐上限" tone={clipLimit && clipQuota / clipLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={HardDrive} label="存储用量" value={`${storageQuota}/${storageLimit} GB`} detail="工作区已记录用量" tone={storageLimit && storageQuota / storageLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={HardDrive} label="存储用量" value={`${formatMetricValue(storageQuota)}/${formatMetricValue(storageLimit)} GB`} detail="工作区已记录用量" tone={storageLimit && storageQuota / storageLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={CircleDollarSign} label="计量成本" value={data.usage?.totalCost ?? 0} detail="本月本地计量事件" tone="neutral" />
|
||||
</div>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="组织套餐策略" detail={canManagePlan ? "可编辑" : "只读 · 需要账单管理权限"} /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐名称<input value={planForm.planName} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, planName: event.target.value })} /></label><label>账单周期<select value={planForm.billingCycle} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, billingCycle: event.target.value })}><option value="monthly">按月</option><option value="quarterly">按季度</option><option value="annual">按年</option></select></label><label>结算货币<input maxLength="3" value={planForm.currency} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, currency: event.target.value.toUpperCase() })} /></label><label>席位上限<input type="number" min="1" value={planForm.seatLimit} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatLimit: Number(event.target.value) })} /></label><label>存储上限(GB)<input type="number" min="1" value={planForm.storageGb} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageGb: Number(event.target.value) })} /></label><label>月度片段上限<input type="number" min="1" value={planForm.monthlyClipQuota} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, monthlyClipQuota: Number(event.target.value) })} /></label><label>配额预警阈值(%)<input type="number" min="50" max="99" value={planForm.quotaWarningPercent} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, quotaWarningPercent: Number(event.target.value) })} /></label><label className="checkbox-field"><input type="checkbox" checked={planForm.localRunnerOnly} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, localRunnerOnly: event.target.checked })} />只允许本地 Runner</label><label className="checkbox-field"><input type="checkbox" checked={planForm.cloudConnectorsRequireApproval} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, cloudConnectorsRequireApproval: event.target.checked })} />外部连接器必须审批</label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存套餐策略"}</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="席位状态" detail="活跃成员 + 待处理邀请" /><div className="commercial-seat-meter"><div><span>已占用</span><strong>{seat.active || 0}</strong></div><div><span>待入组</span><strong>{seat.pending || 0}</strong></div><div><span>可用</span><strong>{seat.remaining || 0}</strong></div></div><div className="quota-track"><span style={{ width: `${Math.min(100, seat.utilization || 0)}%` }} /></div><p className="muted-copy">新邀请和 SSO/SCIM 入组都会经过席位检查;达到上限时由后端返回 `409 seat_limit_reached`。</p></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card"><SectionBar title="工作区配额" detail={`${(data.quotas || []).length} 条配额 · 工作区额度不能超过组织套餐`} /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>工作区</span><span>指标</span><span>已用</span><span>上限</span><span>剩余</span><span>周期</span><span>操作</span></div>{(data.quotas || []).map((quota) => <div className="enterprise-table-row" key={quota.id}><strong>{quota.workspaceName}</strong><span>{quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric}</span><span>{quota.usedValue} {quota.unit}</span><label className="quota-edit-input"><input type="number" min={quota.usedValue} value={quotaDrafts[quota.id] ?? quota.limitValue} disabled={!canManageQuota} onChange={(event) => setQuotaDrafts({ ...quotaDrafts, [quota.id]: event.target.value })} /><span>{quota.unit}</span></label><span>{quota.remainingValue} {quota.unit}</span><span>{String(quota.period_end || "").slice(0, 10)}</span><button className="icon-text-button" onClick={() => saveQuota(quota)} disabled={!canManageQuota || busy === quota.id}><Save size={13} />{busy === quota.id ? "保存中" : "保存"}</button></div>)}{!(data.quotas || []).length && <div className="empty-table">当前组织还没有工作区配额。</div>}</div></section>
|
||||
<section className="studio-card wide-card commercial-approval-card">
|
||||
<SectionBar title="治理申请中心" detail={`${commercialApprovalSummary.submitted || 0} 个待审批 · ${commercialApprovalSummary.urgent || 0} 个紧急`} action={<StatusBadge status={commercialApprovalSummary.submitted ? "needs-evidence" : "active"} label={commercialApprovalSummary.submitted ? "需要处理" : "当前稳定"} />} />
|
||||
<div className="commercial-approval-summary">
|
||||
<div><strong>{commercialApprovalSummary.total || 0}</strong><span>全部申请</span></div>
|
||||
<div><strong>{commercialApprovalSummary.submitted || 0}</strong><span>待审批</span></div>
|
||||
<div><strong>{commercialApprovalSummary.approved || 0}</strong><span>已通过</span></div>
|
||||
<div><strong>{commercialApprovalSummary.rejected || 0}</strong><span>已拒绝</span></div>
|
||||
</div>
|
||||
<form className="commercial-approval-form" onSubmit={submitCommercialApproval}>
|
||||
<label>申请类型<select value={approvalDraft.requestType} onChange={(event) => changeApprovalType(event.target.value)}>{(commercialApprovalCatalog.length ? commercialApprovalCatalog : Object.keys(commercialApprovalTypeLabels).map((key) => ({ key, label: commercialApprovalTypeLabels[key] }))).map((type) => <option key={type.key} value={type.key}>{type.label || commercialApprovalTypeLabels[type.key]}</option>)}</select></label>
|
||||
<label>治理对象<select value={approvalDraft.targetKey || currentApprovalTarget?.value || ""} onChange={(event) => { const target = commercialApprovalTargets.find((item) => item.value === event.target.value); setApprovalDraft({ ...approvalDraft, targetKey: event.target.value, requestedValue: target?.suggested ?? approvalDraft.requestedValue }); }}>{commercialApprovalTargets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}</select></label>
|
||||
<label>目标值<input type="number" min="0" step="1" value={approvalDraft.requestedValue} onChange={(event) => setApprovalDraft({ ...approvalDraft, requestedValue: event.target.value })} /></label>
|
||||
<label>优先级<select value={approvalDraft.priority} onChange={(event) => setApprovalDraft({ ...approvalDraft, priority: event.target.value })}><option value="urgent">紧急</option><option value="high">高</option><option value="medium">普通</option><option value="low">低</option></select></label>
|
||||
<label className="approval-title-field">标题<input value={approvalDraft.title} onChange={(event) => setApprovalDraft({ ...approvalDraft, title: event.target.value })} placeholder={currentApprovalTarget ? `${commercialApprovalTypeLabels[approvalDraft.requestType] || "治理申请"} · ${currentApprovalTarget.label}` : "商业治理申请"} /></label>
|
||||
<label>证据路径<input value={approvalDraft.evidenceRef} onChange={(event) => setApprovalDraft({ ...approvalDraft, evidenceRef: event.target.value })} placeholder="本地合同、授权书、项目说明或审计引用" /></label>
|
||||
<label className="approval-reason-field">申请原因<textarea required rows="3" value={approvalDraft.businessReason} onChange={(event) => setApprovalDraft({ ...approvalDraft, businessReason: event.target.value })} /></label>
|
||||
<div className="approval-policy-note"><ShieldCheck size={15} /><span>通过前不自动放开额度;外部模型/付费云通道仍需显式审批,密钥只允许放在环境变量中。</span></div>
|
||||
<div className="form-actions"><button className="primary" type="submit" disabled={busy === "commercial-approval-submit" || !commercialApprovalTargets.length}><Send size={14} />{busy === "commercial-approval-submit" ? "提交中…" : "提交申请"}</button></div>
|
||||
</form>
|
||||
<div className="enterprise-table commercial-approval-table">
|
||||
<div className="enterprise-table-head"><span>申请</span><span>对象</span><span>数值</span><span>申请人</span><span>状态</span><span>审批备注</span><span>操作</span></div>
|
||||
{commercialApprovals.map((request) => {
|
||||
const canDecide = request.status === "submitted" && canReviewCommercialApprovals;
|
||||
const canCancel = request.status === "submitted" && request.requester?.id === currentUserId;
|
||||
return <div className="enterprise-table-row" key={request.id}><div className="approval-request-main"><span className={`task-priority ${request.priority}`}>{commercialApprovalPriorityLabels[request.priority] || request.priority}</span><strong>{request.title}</strong><small>{request.requestTypeLabel || commercialApprovalTypeLabels[request.requestType] || request.requestType} · {request.businessReason}</small></div><span className="mono">{request.targetKey}</span><strong>{formatMetricValue(request.currentValue)} → {formatMetricValue(request.requestedValue)} {request.unit}</strong><div className="usage-event-cell"><strong>{request.requester?.name || request.requester?.id}</strong><small>{request.projectName || request.workspaceName || "组织级"}</small></div><StatusBadge status={approvalStatusTone(request.status)} label={commercialApprovalStatusLabels[request.status] || request.status} /><label className="approval-note-input"><input value={approvalDecisionNotes[request.id] || ""} disabled={!canDecide} onChange={(event) => setApprovalDecisionNotes((current) => ({ ...current, [request.id]: event.target.value }))} placeholder={request.decisionNote || "审批说明"} /></label><div className="approval-actions">{canDecide && <><button className="icon-text-button" type="button" onClick={() => decideCommercialRequest(request, "approved")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><CheckCircle2 size={13} />通过</button><button className="icon-text-button danger" type="button" onClick={() => decideCommercialRequest(request, "rejected")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><XCircle size={13} />拒绝</button></>}{canCancel && <button className="icon-text-button" type="button" onClick={() => decideCommercialRequest(request, "cancelled")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><Ban size={13} />取消</button>}{!canDecide && !canCancel && <span>{request.reviewer?.name || "等待处理"}</span>}</div></div>;
|
||||
})}
|
||||
{!commercialApprovals.length && <div className="empty-table">当前没有商业治理申请。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card entitlement-matrix-card">
|
||||
<SectionBar title="套餐权益矩阵" detail={`${entitlementSummary.enabled || 0}/${entitlementSummary.total || 0} 项启用 · ${entitlementSummary.overridden || 0} 项组织覆盖`} />
|
||||
<div className="plan-template-strip">{planTemplates.map((plan) => <div key={plan.id}><div><strong>{plan.name}</strong><StatusBadge status={plan.name === data.billing?.plan_name ? "active" : "neutral"} label={plan.name === data.billing?.plan_name ? "当前" : plan.supportSla || "模板"} /></div><span>{plan.seatLimit} 席 · {plan.storageGb}GB · {plan.monthlyClipQuota} clips/月</span><small>{plan.description}</small></div>)}</div>
|
||||
<div className="entitlement-summary-row"><div><strong>{entitlementSummary.blocked || 0}</strong><span>已阻断</span></div><div><strong>{entitlementSummary.warning || 0}</strong><span>预警</span></div><div><strong>{entitlementSummary.overridden || 0}</strong><span>组织覆盖</span></div><div><strong>{entitlements.filter((item) => item.key.startsWith("feature.") && item.enabled).length}</strong><span>已开通功能</span></div></div>
|
||||
{entitlementCategories.map(([category, label]) => <div className="entitlement-category" key={category}><div className="subsection-heading"><strong>{label}</strong><span>{entitlements.filter((item) => item.category === category).length} 项</span></div><div className="enterprise-table entitlement-table"><div className="enterprise-table-head"><span>权益</span><span>已用</span><span>额度</span><span>开通</span><span>执行</span><span>状态</span><span>操作</span></div>{entitlements.filter((item) => item.category === category).map((entitlement) => { const draft = entitlementDrafts[entitlement.key] || {}; const badgeStatus = entitlement.status === "blocked" ? "failed" : entitlement.status === "warning" ? "needs-evidence" : entitlement.status === "disabled" ? "disabled" : "active"; return <div className="enterprise-table-row" key={entitlement.key}><div className="entitlement-name-cell"><strong>{entitlement.label}</strong><small>{entitlement.key} · {entitlement.source === "override" ? "组织覆盖" : "套餐继承"}</small></div><span>{formatMetricValue(entitlement.usedValue)} {entitlement.unit}</span><label className="quota-edit-input"><input type="number" min={entitlement.usedValue || 0} value={draft.limitValue ?? entitlement.limitValue} disabled={!canManageEntitlements} onChange={(event) => setEntitlementDraft(entitlement.key, { limitValue: event.target.value })} /><span>{entitlement.unit}</span></label><button type="button" className={`switch-control small ${draft.enabled ?? entitlement.enabled ? "on" : ""}`} disabled={!canManageEntitlements} onClick={() => setEntitlementDraft(entitlement.key, { enabled: !(draft.enabled ?? entitlement.enabled) })}><span />{draft.enabled ?? entitlement.enabled ? "开通" : "关闭"}</button><select value={draft.enforcement || entitlement.enforcement} disabled={!canManageEntitlements} onChange={(event) => setEntitlementDraft(entitlement.key, { enforcement: event.target.value })}><option value="block">阻断</option><option value="warn">预警</option><option value="off">记录</option></select><StatusBadge status={badgeStatus} label={entitlement.status === "blocked" ? "已达上限" : entitlement.status === "warning" ? `${entitlement.utilization}%` : entitlement.status === "disabled" ? "未开通" : "可用"} /><button className="icon-text-button" type="button" onClick={() => saveEntitlement(entitlement)} disabled={!canManageEntitlements || busy === `entitlement-${entitlement.key}`}><Save size={13} />{busy === `entitlement-${entitlement.key}` ? "保存中" : "保存"}</button></div>; })}</div></div>)}
|
||||
</section>
|
||||
<section className="studio-card wide-card"><SectionBar title="工作区配额" detail={`${(data.quotas || []).length} 条配额 · 工作区额度不能超过组织套餐`} /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>工作区</span><span>指标</span><span>已用</span><span>上限</span><span>剩余</span><span>周期</span><span>操作</span></div>{(data.quotas || []).map((quota) => <div className="enterprise-table-row" key={quota.id}><strong>{quota.workspaceName}</strong><span>{quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric}</span><span>{formatMetricValue(quota.usedValue)} {quota.unit}</span><label className="quota-edit-input"><input type="number" min={quota.usedValue} value={quotaDrafts[quota.id] ?? quota.limitValue} disabled={!canManageQuota} onChange={(event) => setQuotaDrafts({ ...quotaDrafts, [quota.id]: event.target.value })} /><span>{quota.unit}</span></label><span>{formatMetricValue(quota.remainingValue)} {quota.unit}</span><span>{String(quota.period_end || "").slice(0, 10)}</span><button className="icon-text-button" onClick={() => saveQuota(quota)} disabled={!canManageQuota || busy === quota.id}><Save size={13} />{busy === quota.id ? "保存中" : "保存"}</button></div>)}{!(data.quotas || []).length && <div className="empty-table">当前组织还没有工作区配额。</div>}</div></section>
|
||||
<div className="enterprise-grid enterprise-grid-main commercial-alert-grid"><section className="studio-card"><SectionBar title="配额预警" detail={`${(data.quotaWarnings || []).length} 个需要关注`} /><div className="policy-check-list">{(data.quotaWarnings || []).map((warning) => <button className="policy-check-row" type="button" key={`${warning.metric}-${warning.workspaceId || "org"}`} onClick={() => focusUsageByWorkspace(warning.workspaceId)}><AlertTriangle size={17} /><span>{warning.workspaceName || "组织"} · {warning.label}</span><StatusBadge status={warning.status === "critical" ? "failed" : "needs-evidence"} label={`${warning.utilization}%`} /></button>)}{!(data.quotaWarnings || []).length && <div className="empty-table">当前没有达到预警阈值的额度。</div>}</div></section><section className="studio-card"><SectionBar title="账期与变更记录" detail={`${data.billing?.billing_cycle || "monthly"} · 下一账期 ${String(data.billing?.next_invoice_at || "").slice(0, 10) || "未设置"}`} /><div className="audit-enterprise-list compact-audit-list">{(data.billingHistory || []).slice(0, 5).map((event) => <div key={event.id}><div className="audit-time">{formatAdminDate(event.created_at)}</div><div className="audit-main"><strong>{event.event_type}</strong><span>{event.actor_name || "system"}</span></div><StatusBadge status="active" label="已记录" /></div>)}{!(data.billingHistory || []).length && <div className="empty-table">还没有套餐变更记录。</div>}</div></section></div>
|
||||
<section className="studio-card wide-card"><SectionBar title="成本中心" detail="按组织运营预算与实际计量拆分" /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>成本中心</span><span>本月使用</span><span>月度预算</span><span>剩余</span><span>利用率</span><span>操作</span></div>{(data.costCenters || []).map((center) => <div className="enterprise-table-row" key={center.id}><strong>{center.name}</strong><span>{center.used} {center.currency}</span><label className="quota-edit-input"><input type="number" min="0" value={costCenterDrafts[center.id] ?? center.monthly_budget} disabled={!canManagePlan} onChange={(event) => setCostCenterDrafts({ ...costCenterDrafts, [center.id]: event.target.value })} /><span>{center.currency}</span></label><span>{center.remaining} {center.currency}</span><StatusBadge status={center.utilization >= 100 ? "failed" : center.utilization >= 80 ? "needs-evidence" : "active"} label={`${center.utilization}%`} /><button className="icon-text-button" onClick={() => saveCostCenter(center)} disabled={!canManagePlan || busy === center.id}><Save size={13} />{busy === center.id ? "保存中" : "保存"}</button></div>)}{!(data.costCenters || []).length && <div className="empty-table">当前组织没有成本中心。</div>}</div><div className="commercial-usage-list cost-center-detail-list">{(data.costCenterDetail || []).slice(0, 8).map((item) => <div key={`${item.code}-${item.workspaceId || "org"}`}><div><strong>{item.code}</strong><span>{item.workspaceName}</span></div><b>{item.units}</b><em>{item.cost} CNY · {item.events} 事件</em><button className="icon-text-button" type="button" onClick={() => focusUsageByCostCenter(item.code)}><ArrowUpRight size={13} />查看明细</button></div>)}</div></section>
|
||||
<section className="studio-card wide-card"><SectionBar title="账单计价参数" detail="发票生成使用的本地计价快照" /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐固定费<input type="number" min="0" step="0.01" value={planForm.baseFee} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, baseFee: Number(event.target.value) })} /></label><label>席位单价<input type="number" min="0" step="0.01" value={planForm.seatUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatUnitPrice: Number(event.target.value) })} /></label><label>存储单价 / GB<input type="number" min="0" step="0.01" value={planForm.storageUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageUnitPrice: Number(event.target.value) })} /></label><label>片段单价<input type="number" min="0" step="0.01" value={planForm.clipUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, clipUnitPrice: Number(event.target.value) })} /></label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存计价参数"}</button></div></form></section>
|
||||
@@ -1709,15 +1904,128 @@ function ModelConnectorFields({ form, setForm, widePrefix = "" }) {
|
||||
export function AdminModelsPage({ platformContext, contextOverrides, onModelsChanged }) {
|
||||
const platform = platformContext?.platform || {};
|
||||
const [models, setModels] = useState(platform.modelRegistry || []);
|
||||
const [catalog, setCatalog] = useState(platform.modelCatalog || []);
|
||||
const [routes, setRoutes] = useState(platform.modelRouting || []);
|
||||
const [form, setForm] = useState(emptyModelConnectorForm);
|
||||
const [editingModel, setEditingModel] = useState(null);
|
||||
const [editForm, setEditForm] = useState(emptyModelConnectorForm);
|
||||
const [catalogForm, setCatalogForm] = useState({ connectorId: "", displayName: "", modelKey: "", family: "", capabilities: "chat", contextWindow: 131072, maxOutputTokens: 8192, locality: "local", estimatedCny: "0.03", status: "active", approvalStatus: "approved", notes: "" });
|
||||
const [editingCatalog, setEditingCatalog] = useState(null);
|
||||
const [catalogEditForm, setCatalogEditForm] = useState({ connectorId: "", displayName: "", modelKey: "", family: "", capabilities: "", contextWindow: 0, maxOutputTokens: 0, locality: "local", estimatedCny: "0", status: "active", approvalStatus: "approved", notes: "" });
|
||||
const [routeForm, setRouteForm] = useState({ name: "", workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", primaryModelId: "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "10", status: "active", notes: "" });
|
||||
const [editingRoute, setEditingRoute] = useState(null);
|
||||
const [routeEditForm, setRouteEditForm] = useState({ name: "", workflowKey: "", operationKey: "", primaryModelId: "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "0", status: "active", notes: "" });
|
||||
const [resolutionForm, setResolutionForm] = useState({ workflowKey: "ai-manhua-drama", operationKey: "image-keyframe", kind: "单画面关键帧", adapter: "owned-model-platform", modelId: "", localOnly: true, estimatedCostCny: "0" });
|
||||
const [resolution, setResolution] = useState(null);
|
||||
const [approvals, setApprovals] = useState([]);
|
||||
const [approvalSummary, setApprovalSummary] = useState({ submitted: 0, approved: 0, rejected: 0, expired: 0 });
|
||||
const [approvalReason, setApprovalReason] = useState("本次生产需要使用受控模型路由,请审批一次执行窗口。");
|
||||
const [approvalBusy, setApprovalBusy] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canApproveRoutes = Boolean(platformContext?.context?.systemAdmin || permissions.has("model:approve") || permissions.has("model:manage"));
|
||||
|
||||
useEffect(() => setModels(platform.modelRegistry || []), [platform.modelRegistry]);
|
||||
useEffect(() => setCatalog(platform.modelCatalog || []), [platform.modelCatalog]);
|
||||
useEffect(() => setRoutes(platform.modelRouting || []), [platform.modelRouting]);
|
||||
useEffect(() => {
|
||||
if (!catalogForm.connectorId && (platform.modelRegistry || [])[0]?.id) setCatalogForm((current) => ({ ...current, connectorId: (platform.modelRegistry || [])[0].id }));
|
||||
}, [platform.modelRegistry]);
|
||||
useEffect(() => {
|
||||
if (!routeForm.primaryModelId && (platform.modelCatalog || [])[0]?.id) setRouteForm((current) => ({ ...current, primaryModelId: (platform.modelCatalog || [])[0].id }));
|
||||
}, [platform.modelCatalog]);
|
||||
useEffect(() => {
|
||||
loadModelOps();
|
||||
}, [contextOverrides.organizationId, contextOverrides.workspaceId]);
|
||||
|
||||
function syncModels(nextModels) {
|
||||
setModels(nextModels);
|
||||
onModelsChanged?.(nextModels);
|
||||
}
|
||||
|
||||
function catalogFormFromEntry(entry) {
|
||||
return {
|
||||
connectorId: entry.connectorId || entry.connector_id || "",
|
||||
displayName: entry.display_name || entry.displayName || "",
|
||||
modelKey: entry.model_key || entry.modelKey || "",
|
||||
family: entry.family || "",
|
||||
capabilities: (entry.capabilities || []).join(", "),
|
||||
contextWindow: entry.contextWindow || entry.context_window || 0,
|
||||
maxOutputTokens: entry.maxOutputTokens || entry.max_output_tokens || 0,
|
||||
locality: entry.cost?.locality || "local",
|
||||
estimatedCny: String(entry.cost?.estimatedCny ?? entry.cost?.estimated_cny ?? 0),
|
||||
status: entry.status || "active",
|
||||
approvalStatus: entry.approval_status || entry.approvalStatus || "approved",
|
||||
notes: entry.metadata?.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function routeFormFromEntry(route) {
|
||||
return {
|
||||
name: route.name || "",
|
||||
workflowKey: route.workflow_key || route.workflowKey || "",
|
||||
operationKey: route.operation_key || route.operationKey || "",
|
||||
primaryModelId: route.primaryModelId || route.primary_model_id || "",
|
||||
fallbackModelId: route.fallbackModelId || route.fallback_model_id || "",
|
||||
policyMode: route.policy_mode || route.policyMode || "prefer-local",
|
||||
approvalMode: route.approval_mode || route.approvalMode || "follow-model",
|
||||
budgetLimitCny: String(route.budgetLimitCny ?? route.budget_limit_cny ?? 0),
|
||||
status: route.status || "active",
|
||||
notes: route.policy?.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function buildCatalogPayload(source) {
|
||||
return {
|
||||
connectorId: source.connectorId,
|
||||
displayName: source.displayName,
|
||||
modelKey: source.modelKey,
|
||||
family: source.family,
|
||||
capabilities: source.capabilities.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
contextWindow: Number(source.contextWindow || 0),
|
||||
maxOutputTokens: Number(source.maxOutputTokens || 0),
|
||||
cost: {
|
||||
mode: "estimated",
|
||||
estimatedCny: Number(source.estimatedCny || 0),
|
||||
locality: source.locality || "local"
|
||||
},
|
||||
status: source.status,
|
||||
approvalStatus: source.approvalStatus,
|
||||
metadata: { notes: source.notes || "" }
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutePayload(source) {
|
||||
return {
|
||||
name: source.name,
|
||||
workflowKey: source.workflowKey,
|
||||
operationKey: source.operationKey,
|
||||
primaryModelId: source.primaryModelId,
|
||||
fallbackModelId: source.fallbackModelId || "",
|
||||
policyMode: source.policyMode,
|
||||
approvalMode: source.approvalMode,
|
||||
budgetLimitCny: Number(source.budgetLimitCny || 0),
|
||||
status: source.status,
|
||||
policy: { notes: source.notes || "" }
|
||||
};
|
||||
}
|
||||
|
||||
async function loadModelOps() {
|
||||
try {
|
||||
const [catalogResult, routeResult, approvalResult] = await Promise.all([
|
||||
fetchModelCatalog(contextOverrides),
|
||||
fetchModelRoutes(contextOverrides),
|
||||
fetchModelRouteApprovals({}, contextOverrides)
|
||||
]);
|
||||
setCatalog(catalogResult.catalog || []);
|
||||
setRoutes(routeResult.routes || []);
|
||||
setApprovals(approvalResult.approvals || []);
|
||||
setApprovalSummary(approvalResult.summary || { submitted: 0, approved: 0, rejected: 0, expired: 0 });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -1729,15 +2037,18 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
syncModels(result.models || [result.model, ...models]);
|
||||
setForm((current) => ({ ...current, label: "" }));
|
||||
setNotice("模型连接器已写入当前组织");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(model) {
|
||||
setEditingModel(model);
|
||||
setEditForm(modelConnectorForm(model));
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
async function saveEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingModel) return;
|
||||
@@ -1751,10 +2062,12 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
syncModels(nextModels);
|
||||
setEditingModel(null);
|
||||
setNotice("模型连接器配置已更新,密钥仍只通过环境变量引用");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(model) {
|
||||
try {
|
||||
const result = await probeModel(model.id, contextOverrides);
|
||||
@@ -1764,6 +2077,7 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(model) {
|
||||
try {
|
||||
const nextStatus = model.status === "paused" ? "ready" : "paused";
|
||||
@@ -1771,23 +2085,210 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
const nextModels = models.map((item) => item.id === model.id ? result.model : item);
|
||||
syncModels(nextModels);
|
||||
setNotice(`${model.label} 已${nextStatus === "ready" ? "启用" : "暂停"}`);
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCatalog(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await createModelCatalog(buildCatalogPayload(catalogForm), contextOverrides);
|
||||
setCatalog(result.catalog || []);
|
||||
setCatalogForm({ connectorId: models[0]?.id || "", displayName: "", modelKey: "", family: "", capabilities: "chat", contextWindow: 131072, maxOutputTokens: 8192, locality: "local", estimatedCny: "0.03", status: "active", approvalStatus: "approved", notes: "" });
|
||||
setNotice("模型目录条目已创建");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCatalogEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingCatalog) return;
|
||||
try {
|
||||
const result = await updateModelCatalog(editingCatalog.id, buildCatalogPayload(catalogEditForm), contextOverrides);
|
||||
setCatalog((result.catalog || catalog).map((item) => item.id === editingCatalog.id ? result.entry || item : item));
|
||||
setEditingCatalog(null);
|
||||
setNotice("模型目录条目已更新");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoute(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await createModelRoute(buildRoutePayload(routeForm), contextOverrides);
|
||||
setRoutes(result.routes || []);
|
||||
setRouteForm({ name: "", workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", primaryModelId: catalog[0]?.id || "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "10", status: "active", notes: "" });
|
||||
setNotice("路由策略已创建");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRouteEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingRoute) return;
|
||||
try {
|
||||
const result = await updateModelRoute(editingRoute.id, buildRoutePayload(routeEditForm), contextOverrides);
|
||||
setRoutes((result.routes || routes).map((item) => item.id === editingRoute.id ? result.route || item : item));
|
||||
setEditingRoute(null);
|
||||
setNotice("路由策略已更新");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRouteResolution(event) {
|
||||
event?.preventDefault?.();
|
||||
try {
|
||||
const payload = {
|
||||
...resolutionForm,
|
||||
localOnly: Boolean(resolutionForm.localOnly),
|
||||
estimatedCostCny: Number(resolutionForm.estimatedCostCny || 0),
|
||||
modelId: resolutionForm.modelId || undefined
|
||||
};
|
||||
const result = await resolveModelRoute(payload, contextOverrides);
|
||||
setResolution(result.resolution);
|
||||
setNotice(result.resolution.guard.status === "pass" ? "路由试算通过,可按该策略进入任务队列。" : result.resolution.guard.status === "approval-required" ? "路由试算需要审批,执行前会被审批门拦截。" : "路由试算发现阻断项,请调整连接器、模型或成本策略。");
|
||||
} catch (error) {
|
||||
setResolution(null);
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRouteApproval() {
|
||||
try {
|
||||
setApprovalBusy("submit");
|
||||
const payload = {
|
||||
...resolutionForm,
|
||||
localOnly: Boolean(resolutionForm.localOnly),
|
||||
estimatedCostCny: Number(resolutionForm.estimatedCostCny || 0),
|
||||
modelId: resolutionForm.modelId || undefined,
|
||||
reason: approvalReason,
|
||||
approvalScope: "single-run"
|
||||
};
|
||||
const result = await requestModelRouteApproval(payload, contextOverrides);
|
||||
setApprovals(result.approvals || []);
|
||||
setApprovalSummary(result.summary || approvalSummary);
|
||||
setNotice(`模型路由审批单已提交:${result.approval?.id || ""}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setApprovalBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function decideRouteApproval(approval, status) {
|
||||
if (!approval?.id) return;
|
||||
try {
|
||||
setApprovalBusy(approval.id);
|
||||
const result = await decideModelRouteApproval(approval.id, {
|
||||
status,
|
||||
expiresHours: status === "approved" ? 24 : undefined,
|
||||
note: status === "approved" ? "批准一次受控模型执行,24 小时内有效。" : "当前连接器、成本或模型证据不满足放行要求。"
|
||||
}, contextOverrides);
|
||||
setApprovals(result.approvals || []);
|
||||
setApprovalSummary(result.summary || approvalSummary);
|
||||
setNotice(status === "approved" ? `已批准审批单:${approval.id}` : `已驳回审批单:${approval.id}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setApprovalBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function approvalLabel(status) {
|
||||
return ({ submitted: "待审批", approved: "已批准", rejected: "已驳回", expired: "已过期", cancelled: "已取消" })[status] || status || "未知";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / MODEL OPS" title="模型与 Runner 中台" description="组织级管理本地模型平台、OpenAI-compatible 接口和可选 ComfyUI 桥接,所有连接器都要有能力标签、成本策略和审批状态。" />
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / MODEL OPS" title="模型与 Runner 中台" description="把连接器、模型目录和路由策略拆开治理:连接器解决怎么接,模型目录解决有哪些模型,路由策略解决业务流程到底走谁。" action={<div className="header-actions"><StatusBadge status="active" label="默认 local-only" /><button className="subtle" onClick={loadModelOps}><RefreshCw size={15} />刷新目录</button></div>} />
|
||||
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="连接器注册表" detail={`${models.length} 个连接器`} action={<StatusBadge status="active" label="默认 local-only" />} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{models.map((model) => <div className="enterprise-table-row" key={model.id}><strong>{model.label}</strong><span>{(model.capability || []).join(" · ")}</span><span className="mono">{model.endpoint}<small className="model-probe-meta">{model.last_probe_at ? `最近探活 ${model.latency_ms || "-"}ms` : "尚未探活"}</small></span><span>{model.costMode || model.cost_mode}</span><span>{model.approvalRequired || model.approval_required ? "需要审批" : "无需"}</span><StatusBadge status={model.status} label={model.status === "not-connected" ? "未连接" : model.status === "optional" ? "可选" : model.status === "error" ? "错误" : model.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => openEditor(model)}><Settings2 size={13} />编辑</button><button className="icon-text-button" onClick={() => probe(model)}><Activity size={13} />检测</button>{["ready", "paused"].includes(model.status) && <button className="icon-text-button" onClick={() => toggle(model)}><ServerCog size={13} />{model.status === "paused" ? "启用" : "暂停"}</button>}</div></div>)}</div>
|
||||
<SectionBar title="连接器注册表" detail={`${models.length} 个连接器`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{models.map((model) => <div className="enterprise-table-row" key={model.id}><strong>{model.label}</strong><span>{(model.capability || []).join(" · ")}</span><span className="mono">{model.endpoint}<small className="model-probe-meta">{model.last_probe_at ? `最近探活 ${model.latency_ms || "-"}ms` : "尚未探活"}</small><small className={`model-secret-meta ${model.secretPolicy?.status || "not-required"}`}>{model.endpointPolicy?.locality === "public" ? "公网 endpoint" : "本地/内网 endpoint"} · {model.secretPolicy?.required ? `${model.secretPolicy.authEnv || "未配置密钥变量"} ${model.secretPolicy.present ? "已配置" : "缺失"}` : "无需密钥"}</small></span><span>{model.costMode || model.cost_mode}</span><span>{model.approvalRequired || model.approval_required ? "需要审批" : "无需"}</span><StatusBadge status={model.status} label={model.status === "not-connected" ? "未连接" : model.status === "optional" ? "可选" : model.status === "error" ? "错误" : model.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => openEditor(model)}><Settings2 size={13} />编辑</button><button className="icon-text-button" onClick={() => probe(model)}><Activity size={13} />检测</button>{["ready", "paused"].includes(model.status) && <button className="icon-text-button" onClick={() => toggle(model)}><ServerCog size={13} />{model.status === "paused" ? "启用" : "暂停"}</button>}</div></div>)}</div>
|
||||
</section>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="注册新连接器" detail="只保存连接信息,不直接调用外部服务" /><form className="enterprise-form-grid" onSubmit={submit}><ModelConnectorFields form={form} setForm={setForm} widePrefix="register-model-fields" /><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />注册连接器</button></div></form></section>
|
||||
<section className="studio-card wide-card"><SectionBar title="注册新连接器" detail="只保存连接信息,不直接保存密钥" /><form className="enterprise-form-grid" onSubmit={submit}><ModelConnectorFields form={form} setForm={setForm} widePrefix="register-model-fields" /><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />注册连接器</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="连接策略" detail="当前组织生效" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>自有模型平台优先</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>单画面生成契约</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldAlert size={17} /><span>云端节点调用</span><StatusBadge status="disabled" label="默认禁用" /></div><div><Network size={17} /><span>ComfyUI 桥接</span><StatusBadge status="optional" label="可选" /></div></div></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card model-resolution-panel">
|
||||
<SectionBar title="模型路由试算器" detail="不执行任务,只预检业务流程会命中哪个连接器和模型" action={<button className="subtle" onClick={runRouteResolution}><Activity size={15} />试算</button>} />
|
||||
<form className="model-resolution-form" onSubmit={runRouteResolution}>
|
||||
<label>工作流 Key<input value={resolutionForm.workflowKey} onChange={(event) => setResolutionForm((current) => ({ ...current, workflowKey: event.target.value }))} placeholder="ai-manhua-drama" /></label>
|
||||
<label>操作 Key<input value={resolutionForm.operationKey} onChange={(event) => setResolutionForm((current) => ({ ...current, operationKey: event.target.value }))} placeholder="image-keyframe" /></label>
|
||||
<label>任务类型<input value={resolutionForm.kind} onChange={(event) => setResolutionForm((current) => ({ ...current, kind: event.target.value }))} placeholder="单画面关键帧" /></label>
|
||||
<label>直接连接器<select value={resolutionForm.adapter} onChange={(event) => setResolutionForm((current) => ({ ...current, adapter: event.target.value }))}><option value="">只按路由</option><option value="owned-model-platform">自有模型平台自动选择</option>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label>
|
||||
<label>指定模型<select value={resolutionForm.modelId} onChange={(event) => setResolutionForm((current) => ({ ...current, modelId: event.target.value }))}><option value="">按路由主/备模型</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label>
|
||||
<label>预估成本 CNY<input type="number" min="0" step="0.01" value={resolutionForm.estimatedCostCny} onChange={(event) => setResolutionForm((current) => ({ ...current, estimatedCostCny: event.target.value }))} /></label>
|
||||
<label className="checkbox-field model-resolution-checkbox"><input type="checkbox" checked={resolutionForm.localOnly} onChange={(event) => setResolutionForm((current) => ({ ...current, localOnly: event.target.checked }))} />要求 local-only</label>
|
||||
<label className="model-resolution-wide">审批理由<textarea value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} placeholder="说明为什么需要使用该模型、连接器或外部/混合成本策略" /></label>
|
||||
<div className="form-actions"><button className="primary" type="submit"><Activity size={15} />执行试算</button><button className="subtle" type="button" onClick={submitRouteApproval} disabled={approvalBusy === "submit" || resolution?.guard?.status === "blocked"}><ShieldCheck size={15} />{approvalBusy === "submit" ? "提交中…" : "提交审批单"}</button></div>
|
||||
</form>
|
||||
{resolution && <div className="model-resolution-result">
|
||||
<div className={`model-resolution-banner ${resolution.guard.status}`}>
|
||||
{resolution.guard.status === "pass" ? <ShieldCheck size={18} /> : <ShieldAlert size={18} />}
|
||||
<div><strong>{resolution.guard.status === "pass" ? "准入通过" : resolution.guard.status === "approval-required" ? "需要审批" : "已阻断"}</strong><span>{resolution.source} · {resolution.request.workflowKey || "未指定工作流"} / {resolution.request.operationKey || "未指定操作"}</span></div>
|
||||
<StatusBadge status={resolution.guard.status === "pass" ? "active" : resolution.guard.status === "approval-required" ? "review" : "blocked"} label={resolution.guard.status} />
|
||||
</div>
|
||||
<div className="model-resolution-cards">
|
||||
<div><span>命中路由</span><strong>{resolution.route?.routeName || "未命中路由"}</strong><small>{resolution.route?.policyMode || "direct"} · {resolution.route?.approvalMode || "follow-connector"}</small></div>
|
||||
<div><span>模型</span><strong>{resolution.model?.displayName || "直接连接器"}</strong><small>{resolution.model?.modelKey || resolution.connector?.id || "-"}</small></div>
|
||||
<div><span>连接器</span><strong>{resolution.connector?.label || "未解析"}</strong><small>{resolution.connector?.kind || "-"} · {resolution.connector?.costMode || "-"}</small></div>
|
||||
<div><span>成本/预算</span><strong>{Number(resolution.guard.estimatedCny || 0).toFixed(2)} / {Number(resolution.guard.budgetLimitCny || 0).toFixed(2)} CNY</strong><small>{resolution.guard.localOnly ? "local-only 预检" : "允许混合策略"}</small></div>
|
||||
</div>
|
||||
<div className="model-resolution-reasons">
|
||||
{(resolution.guard.reasons || []).map((reason) => <div key={reason.code}><StatusBadge status={reason.severity === "blocking" ? "blocked" : "review"} label={reason.severity} /><strong>{reason.code}</strong><span>{reason.message}</span></div>)}
|
||||
{!resolution.guard.reasons?.length && <div><StatusBadge status="active" label="pass" /><strong>no_blockers</strong><span>没有发现审批或阻断原因。</span></div>}
|
||||
</div>
|
||||
<details className="model-resolution-json"><summary>查看试算 JSON</summary><pre>{JSON.stringify(resolution, null, 2)}</pre></details>
|
||||
</div>}
|
||||
</section>
|
||||
<section className="studio-card wide-card model-approval-panel">
|
||||
<SectionBar title="模型路由审批台" detail={`待审批 ${approvalSummary.submitted || 0} · 已批准 ${approvalSummary.approved || 0} · 过期 ${approvalSummary.expired || 0}`} action={<button className="subtle" onClick={loadModelOps}><RefreshCw size={15} />刷新审批</button>} />
|
||||
<div className="model-approval-kpis">
|
||||
<div><span>待审批</span><strong>{approvalSummary.submitted || 0}</strong><small>需要模型管理员处理</small></div>
|
||||
<div><span>已批准</span><strong>{approvalSummary.approved || 0}</strong><small>一次执行窗口</small></div>
|
||||
<div><span>已驳回</span><strong>{approvalSummary.rejected || 0}</strong><small>保留审计记录</small></div>
|
||||
<div><span>已过期</span><strong>{approvalSummary.expired || 0}</strong><small>需重新申请</small></div>
|
||||
</div>
|
||||
<div className="enterprise-table model-approval-table">
|
||||
<div className="enterprise-table-head"><span>审批单</span><span>申请人</span><span>路由 / 模型</span><span>连接器</span><span>成本</span><span>状态</span><span>有效期</span><span>操作</span></div>
|
||||
{approvals.map((approval) => <div className="enterprise-table-row" key={approval.id}>
|
||||
<strong className="mono">{approval.id}</strong>
|
||||
<span>{approval.requesterName}<small className="model-probe-meta">{approval.projectName || approval.projectId || "工作区级"}</small></span>
|
||||
<span>{approval.routeName || "直接连接器"}<small className="model-probe-meta">{approval.modelDisplayName || approval.modelEntryId || approval.request?.kind || "-"}</small></span>
|
||||
<span>{approval.connectorLabel || approval.connectorId}<small className="model-probe-meta">{approval.connectorKind || "-"}</small></span>
|
||||
<span>{Number(approval.estimatedCostCny || 0).toFixed(2)} CNY<small className="model-probe-meta">{approval.localOnly ? "local-only" : "mixed allowed"}</small></span>
|
||||
<StatusBadge status={approval.status} label={approvalLabel(approval.status)} />
|
||||
<span>{approval.expiresAt ? formatAdminDate(approval.expiresAt) : approval.status === "submitted" ? "待审批" : "无"}</span>
|
||||
<div className="model-actions">{approval.status === "submitted" && canApproveRoutes ? <><button className="icon-text-button" onClick={() => decideRouteApproval(approval, "approved")} disabled={approvalBusy === approval.id}><ShieldCheck size={13} />批准</button><button className="icon-text-button danger" onClick={() => decideRouteApproval(approval, "rejected")} disabled={approvalBusy === approval.id}><XCircle size={13} />驳回</button></> : <span className="muted-mini">{approval.decision_note || approval.reason || "已归档"}</span>}</div>
|
||||
</div>)}
|
||||
{!approvals.length && <div className="empty-table">当前没有模型路由审批单。使用上方试算器命中“需要审批”的路由后可以提交申请。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="模型目录" detail={`${catalog.length} 个模型条目`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>模型 Key</span><span>连接器</span><span>能力</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{catalog.map((entry) => <div className="enterprise-table-row" key={entry.id}><strong>{entry.display_name || entry.displayName}</strong><span className="mono">{entry.model_key || entry.modelKey}<small className="model-probe-meta">{entry.family || "generic"}</small></span><span>{entry.connectorLabel || entry.connector_id || entry.connectorId}</span><span>{(entry.capabilities || []).join(" · ")}</span><span>{Number(entry.cost?.estimatedCny || 0).toFixed(2)} CNY<small className="model-probe-meta">{entry.cost?.locality || "local"}</small></span><span>{entry.approval_status || entry.approvalStatus}</span><StatusBadge status={entry.status} label={entry.status === "active" ? "启用" : entry.status === "review" ? "复核中" : entry.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => { setEditingCatalog(entry); setCatalogEditForm(catalogFormFromEntry(entry)); }}><Settings2 size={13} />编辑</button></div></div>)}</div>
|
||||
</section>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="新增模型条目" detail="一个连接器下可挂多个具体模型" /><form className="enterprise-form-grid" onSubmit={submitCatalog}><label>挂载连接器<select value={catalogForm.connectorId} onChange={(event) => setCatalogForm((current) => ({ ...current, connectorId: event.target.value }))}>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label><label>模型名称<input value={catalogForm.displayName} onChange={(event) => setCatalogForm((current) => ({ ...current, displayName: event.target.value }))} placeholder="例如:Qwen 剧本/小说解析" /></label><label>模型 Key<input value={catalogForm.modelKey} onChange={(event) => setCatalogForm((current) => ({ ...current, modelKey: event.target.value }))} placeholder="例如:qwen-script-parser" /></label><label>模型族<input value={catalogForm.family} onChange={(event) => setCatalogForm((current) => ({ ...current, family: event.target.value }))} placeholder="qwen-text / tts / asr" /></label><label>能力标签<input value={catalogForm.capabilities} onChange={(event) => setCatalogForm((current) => ({ ...current, capabilities: event.target.value }))} placeholder="chat, script-split, knowledge-extract" /></label><label>上下文窗口<input type="number" min="0" value={catalogForm.contextWindow} onChange={(event) => setCatalogForm((current) => ({ ...current, contextWindow: event.target.value }))} /></label><label>最大输出<input type="number" min="0" value={catalogForm.maxOutputTokens} onChange={(event) => setCatalogForm((current) => ({ ...current, maxOutputTokens: event.target.value }))} /></label><label>成本归属<select value={catalogForm.locality} onChange={(event) => setCatalogForm((current) => ({ ...current, locality: event.target.value }))}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label><label>估算成本 CNY<input type="number" min="0" step="0.01" value={catalogForm.estimatedCny} onChange={(event) => setCatalogForm((current) => ({ ...current, estimatedCny: event.target.value }))} /></label><label>状态<select value={catalogForm.status} onChange={(event) => setCatalogForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="review">review</option></select></label><label>审批<select value={catalogForm.approvalStatus} onChange={(event) => setCatalogForm((current) => ({ ...current, approvalStatus: event.target.value }))}><option value="approved">approved</option><option value="review">review</option><option value="pending">pending</option><option value="blocked">blocked</option></select></label><label className="model-editor-wide">说明<textarea value={catalogForm.notes} onChange={(event) => setCatalogForm((current) => ({ ...current, notes: event.target.value }))} placeholder="例如:适合小说/剧本拆解;中文优先。" /></label><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />新增模型条目</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="商业语义" detail="连接器 != 模型" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>连接器</span><StatusBadge status="active" label="解决怎么接" /></div><div><ShieldCheck size={17} /><span>模型目录</span><StatusBadge status="active" label="解决有哪些模型" /></div><div><ShieldCheck size={17} /><span>路由策略</span><StatusBadge status="active" label="解决流程走谁" /></div></div></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="路由策略" detail={`${routes.length} 条业务路由`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>工作流</span><span>操作</span><span>主模型</span><span>回退模型</span><span>预算</span><span>模式</span><span>状态</span><span>操作</span></div>{routes.map((route) => <div className="enterprise-table-row" key={route.id}><strong>{route.name}</strong><span>{route.workflow_key || route.workflowKey}</span><span>{route.operation_key || route.operationKey}</span><span>{route.primaryModelLabel || route.primary_model_id || route.primaryModelId}</span><span>{route.fallbackModelLabel || route.fallback_model_id || route.fallbackModelId || "无"}</span><span>{Number(route.budgetLimitCny || route.budget_limit_cny || 0).toFixed(2)} CNY</span><span>{route.policy_mode || route.policyMode}<small className="model-probe-meta">{route.approval_mode || route.approvalMode}</small></span><StatusBadge status={route.status} label={route.status === "active" ? "启用" : route.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => { setEditingRoute(route); setRouteEditForm(routeFormFromEntry(route)); }}><Settings2 size={13} />编辑</button></div></div>)}</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card"><SectionBar title="新增路由策略" detail="按业务流程选择主模型与回退模型" /><form className="enterprise-form-grid" onSubmit={submitRoute}><label>策略名称<input value={routeForm.name} onChange={(event) => setRouteForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:小说/剧本导入解析" /></label><label>工作流 Key<input value={routeForm.workflowKey} onChange={(event) => setRouteForm((current) => ({ ...current, workflowKey: event.target.value }))} placeholder="knowledge-ingest" /></label><label>操作 Key<input value={routeForm.operationKey} onChange={(event) => setRouteForm((current) => ({ ...current, operationKey: event.target.value }))} placeholder="chunk-and-extract" /></label><label>主模型<select value={routeForm.primaryModelId} onChange={(event) => setRouteForm((current) => ({ ...current, primaryModelId: event.target.value }))}>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>回退模型<select value={routeForm.fallbackModelId} onChange={(event) => setRouteForm((current) => ({ ...current, fallbackModelId: event.target.value }))}><option value="">无</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>策略模式<select value={routeForm.policyMode} onChange={(event) => setRouteForm((current) => ({ ...current, policyMode: event.target.value }))}><option value="prefer-local">prefer-local</option><option value="local-only">local-only</option><option value="prefer-approved">prefer-approved</option><option value="manual-select">manual-select</option></select></label><label>审批模式<select value={routeForm.approvalMode} onChange={(event) => setRouteForm((current) => ({ ...current, approvalMode: event.target.value }))}><option value="follow-model">follow-model</option><option value="explicit-review">explicit-review</option><option value="always-allow">always-allow</option></select></label><label>预算上限 CNY<input type="number" min="0" step="0.01" value={routeForm.budgetLimitCny} onChange={(event) => setRouteForm((current) => ({ ...current, budgetLimitCny: event.target.value }))} /></label><label>状态<select value={routeForm.status} onChange={(event) => setRouteForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="archived">archived</option></select></label><label className="model-editor-wide">路由说明<textarea value={routeForm.notes} onChange={(event) => setRouteForm((current) => ({ ...current, notes: event.target.value }))} placeholder="例如:知识库先走本地解析;若后续接入 embedding,可作为检索补充。" /></label><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />新增路由策略</button></div></form></section>
|
||||
{editingModel && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingModel(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="model-edit-title"><div className="production-modal-head"><div><span className="card-kicker">MODEL CONNECTOR</span><h3 id="model-edit-title">编辑连接器 · {editingModel.label}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingModel(null)} title="关闭编辑连接器" aria-label="关闭编辑连接器"><X size={16} /></button></div><p className="muted-copy">修改会写入当前组织的连接器注册表。平台只保存环境变量名称,不会把 API Key 写入数据库。</p><form className="enterprise-form-grid" onSubmit={saveEdit}><ModelConnectorFields form={editForm} setForm={setEditForm} widePrefix="edit-model-fields" /><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingModel(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存连接器</button></div></form></section></div>}
|
||||
{editingCatalog && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingCatalog(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="catalog-edit-title"><div className="production-modal-head"><div><span className="card-kicker">MODEL CATALOG</span><h3 id="catalog-edit-title">编辑模型条目 · {editingCatalog.display_name || editingCatalog.displayName}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingCatalog(null)} title="关闭编辑模型条目" aria-label="关闭编辑模型条目"><X size={16} /></button></div><form className="enterprise-form-grid" onSubmit={saveCatalogEdit}><label>挂载连接器<select value={catalogEditForm.connectorId} onChange={(event) => setCatalogEditForm((current) => ({ ...current, connectorId: event.target.value }))}>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label><label>模型名称<input value={catalogEditForm.displayName} onChange={(event) => setCatalogEditForm((current) => ({ ...current, displayName: event.target.value }))} /></label><label>模型 Key<input value={catalogEditForm.modelKey} onChange={(event) => setCatalogEditForm((current) => ({ ...current, modelKey: event.target.value }))} /></label><label>模型族<input value={catalogEditForm.family} onChange={(event) => setCatalogEditForm((current) => ({ ...current, family: event.target.value }))} /></label><label>能力标签<input value={catalogEditForm.capabilities} onChange={(event) => setCatalogEditForm((current) => ({ ...current, capabilities: event.target.value }))} /></label><label>上下文窗口<input type="number" min="0" value={catalogEditForm.contextWindow} onChange={(event) => setCatalogEditForm((current) => ({ ...current, contextWindow: event.target.value }))} /></label><label>最大输出<input type="number" min="0" value={catalogEditForm.maxOutputTokens} onChange={(event) => setCatalogEditForm((current) => ({ ...current, maxOutputTokens: event.target.value }))} /></label><label>成本归属<select value={catalogEditForm.locality} onChange={(event) => setCatalogEditForm((current) => ({ ...current, locality: event.target.value }))}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label><label>估算成本 CNY<input type="number" min="0" step="0.01" value={catalogEditForm.estimatedCny} onChange={(event) => setCatalogEditForm((current) => ({ ...current, estimatedCny: event.target.value }))} /></label><label>状态<select value={catalogEditForm.status} onChange={(event) => setCatalogEditForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="review">review</option></select></label><label>审批<select value={catalogEditForm.approvalStatus} onChange={(event) => setCatalogEditForm((current) => ({ ...current, approvalStatus: event.target.value }))}><option value="approved">approved</option><option value="review">review</option><option value="pending">pending</option><option value="blocked">blocked</option></select></label><label className="model-editor-wide">说明<textarea value={catalogEditForm.notes} onChange={(event) => setCatalogEditForm((current) => ({ ...current, notes: event.target.value }))} /></label><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingCatalog(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存模型条目</button></div></form></section></div>}
|
||||
{editingRoute && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingRoute(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="route-edit-title"><div className="production-modal-head"><div><span className="card-kicker">ROUTING POLICY</span><h3 id="route-edit-title">编辑路由策略 · {editingRoute.name}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingRoute(null)} title="关闭编辑路由策略" aria-label="关闭编辑路由策略"><X size={16} /></button></div><form className="enterprise-form-grid" onSubmit={saveRouteEdit}><label>策略名称<input value={routeEditForm.name} onChange={(event) => setRouteEditForm((current) => ({ ...current, name: event.target.value }))} /></label><label>工作流 Key<input value={routeEditForm.workflowKey} onChange={(event) => setRouteEditForm((current) => ({ ...current, workflowKey: event.target.value }))} /></label><label>操作 Key<input value={routeEditForm.operationKey} onChange={(event) => setRouteEditForm((current) => ({ ...current, operationKey: event.target.value }))} /></label><label>主模型<select value={routeEditForm.primaryModelId} onChange={(event) => setRouteEditForm((current) => ({ ...current, primaryModelId: event.target.value }))}>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>回退模型<select value={routeEditForm.fallbackModelId} onChange={(event) => setRouteEditForm((current) => ({ ...current, fallbackModelId: event.target.value }))}><option value="">无</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>策略模式<select value={routeEditForm.policyMode} onChange={(event) => setRouteEditForm((current) => ({ ...current, policyMode: event.target.value }))}><option value="prefer-local">prefer-local</option><option value="local-only">local-only</option><option value="prefer-approved">prefer-approved</option><option value="manual-select">manual-select</option></select></label><label>审批模式<select value={routeEditForm.approvalMode} onChange={(event) => setRouteEditForm((current) => ({ ...current, approvalMode: event.target.value }))}><option value="follow-model">follow-model</option><option value="explicit-review">explicit-review</option><option value="always-allow">always-allow</option></select></label><label>预算上限 CNY<input type="number" min="0" step="0.01" value={routeEditForm.budgetLimitCny} onChange={(event) => setRouteEditForm((current) => ({ ...current, budgetLimitCny: event.target.value }))} /></label><label>状态<select value={routeEditForm.status} onChange={(event) => setRouteEditForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="archived">archived</option></select></label><label className="model-editor-wide">路由说明<textarea value={routeEditForm.notes} onChange={(event) => setRouteEditForm((current) => ({ ...current, notes: event.target.value }))} /></label><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingRoute(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存路由策略</button></div></form></section></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2458,8 +2959,76 @@ function readinessBadge(check) {
|
||||
return map[check.status] || { status: "neutral", label: check.status || "未知" };
|
||||
}
|
||||
|
||||
function emptyPlanTemplateForm() {
|
||||
return {
|
||||
id: "",
|
||||
tierKey: "",
|
||||
name: "",
|
||||
description: "",
|
||||
billingCycle: "monthly",
|
||||
currency: "CNY",
|
||||
baseFee: 0,
|
||||
seatLimit: 5,
|
||||
storageGb: 256,
|
||||
monthlyClipQuota: 500,
|
||||
supportSla: "工作日响应",
|
||||
status: "active",
|
||||
limitsJson: JSON.stringify({ workspaces: 2, projects: 8, modelConnectors: 6, apiClients: 3, knowledgeDocuments: 80, knowledgeContextPacks: 40, deliveryChannels: 3 }, null, 2),
|
||||
featuresJson: JSON.stringify({ batchGeneration: false, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, null, 2),
|
||||
connectorPolicyJson: JSON.stringify({ allowedCostModes: ["local"], externalApprovalRequired: true }, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function planTemplateFormFromPlan(plan) {
|
||||
return {
|
||||
id: plan.id || "",
|
||||
tierKey: plan.tierKey || "",
|
||||
name: plan.name || "",
|
||||
description: plan.description || "",
|
||||
billingCycle: plan.billingCycle || "monthly",
|
||||
currency: plan.currency || "CNY",
|
||||
baseFee: Number(plan.baseFee || 0),
|
||||
seatLimit: Number(plan.seatLimit || 1),
|
||||
storageGb: Number(plan.storageGb || 1),
|
||||
monthlyClipQuota: Number(plan.monthlyClipQuota || 1),
|
||||
supportSla: plan.supportSla || "",
|
||||
status: plan.status || "active",
|
||||
limitsJson: JSON.stringify(plan.limits || {}, null, 2),
|
||||
featuresJson: JSON.stringify(plan.features || {}, null, 2),
|
||||
connectorPolicyJson: JSON.stringify(plan.connectorPolicy || {}, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function parsePlanTemplateForm(form) {
|
||||
const parseObject = (value, label) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not-object");
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} 必须是 JSON 对象`);
|
||||
}
|
||||
};
|
||||
return {
|
||||
tierKey: form.tierKey,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
billingCycle: form.billingCycle,
|
||||
currency: form.currency,
|
||||
baseFee: Number(form.baseFee || 0),
|
||||
seatLimit: Number(form.seatLimit || 0),
|
||||
storageGb: Number(form.storageGb || 0),
|
||||
monthlyClipQuota: Number(form.monthlyClipQuota || 0),
|
||||
supportSla: form.supportSla,
|
||||
status: form.status,
|
||||
limits: parseObject(form.limitsJson, "limits"),
|
||||
features: parseObject(form.featuresJson, "features"),
|
||||
connectorPolicy: parseObject(form.connectorPolicyJson, "connectorPolicy")
|
||||
};
|
||||
}
|
||||
|
||||
export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
const [config, setConfig] = useState({ settings: [], featureFlags: [], notifications: [], apiClients: [], apiClientScopes: fallbackApiClientScopes });
|
||||
const [config, setConfig] = useState({ settings: [], featureFlags: [], planTemplates: [], notifications: [], apiClients: [], apiClientScopes: fallbackApiClientScopes });
|
||||
const [health, setHealth] = useState({ services: [], summary: {} });
|
||||
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
|
||||
const [storage, setStorage] = useState(null);
|
||||
@@ -2473,6 +3042,8 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
const [apiKeyNotice, setApiKeyNotice] = useState("");
|
||||
const [backupBusy, setBackupBusy] = useState(false);
|
||||
const [cleanupBusy, setCleanupBusy] = useState(false);
|
||||
const [planTemplateForm, setPlanTemplateForm] = useState(emptyPlanTemplateForm());
|
||||
const [planTemplateBusy, setPlanTemplateBusy] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -2565,6 +3136,38 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlanTemplate(event) {
|
||||
event.preventDefault();
|
||||
setPlanTemplateBusy("save");
|
||||
try {
|
||||
const body = parsePlanTemplateForm(planTemplateForm);
|
||||
const result = planTemplateForm.id
|
||||
? await updateSystemPlanTemplate(planTemplateForm.id, body, contextOverrides)
|
||||
: await createSystemPlanTemplate(body, contextOverrides);
|
||||
setConfig((current) => ({ ...current, planTemplates: result.planTemplates || current.planTemplates }));
|
||||
setPlanTemplateForm(planTemplateFormFromPlan(result.planTemplate || (result.planTemplates || []).find((plan) => plan.tierKey === body.tierKey) || body));
|
||||
setNotice(`套餐模板“${body.name}”已保存,并写入系统审计日志`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setPlanTemplateBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function archivePlanTemplate(plan) {
|
||||
setPlanTemplateBusy(plan.id);
|
||||
try {
|
||||
const result = await updateSystemPlanTemplate(plan.id, { ...plan, status: plan.status === "archived" ? "active" : "archived" }, contextOverrides);
|
||||
setConfig((current) => ({ ...current, planTemplates: result.planTemplates || current.planTemplates }));
|
||||
setPlanTemplateForm(planTemplateFormFromPlan(result.planTemplate || plan));
|
||||
setNotice(`套餐模板“${plan.name}”已${plan.status === "archived" ? "恢复" : "归档"}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setPlanTemplateBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
setBackupBusy(true);
|
||||
try {
|
||||
@@ -2618,6 +3221,28 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
{(section === "overview" || section === "deployment") && <section className="studio-card wide-card"><SectionBar title="生产就绪度" detail={`${readiness.profile || "local-development"} · ${readiness.summary?.attention || 0} 项需要关注`} action={<StatusBadge status={readiness.summary?.status === "ready" ? "ready" : readiness.summary?.status === "blocked" ? "failed" : "needs-evidence"} label={readiness.summary?.status === "ready" ? "可运行" : readiness.summary?.status === "blocked" ? "有阻断项" : "需处理"} />} /><div className="readiness-grid">{(readiness.checks || []).map((check) => { const badge = readinessBadge(check); return <div key={check.key}><div><strong>{check.label}</strong><StatusBadge status={badge.status} label={badge.label} /></div><span>{check.detail}</span>{check.envName && <small>{check.envName}</small>}</div>; })}</div></section>}
|
||||
{(section !== "notifications" && section !== "overview" && section !== "features") && <section className="studio-card wide-card"><SectionBar title="系统配置" detail={dirtySettings.length ? `${dirtySettings.length} 项未保存` : "已同步"} action={<span className="setting-scope"><Database size={14} />SQLite / system_settings</span>} /><div className="settings-list">{visibleSettings.map((setting) => <div className="system-setting-row" key={setting.key}><div><strong>{settingLabels[setting.category] || setting.category} · {setting.key}</strong><span>{setting.description}</span></div><SettingInput setting={setting} value={setting.value} onChange={(value) => setSetting(setting.key, value)} /></div>)}</div></section>}
|
||||
{(section === "overview" || section === "generation" || section === "deployment" || section === "features") && <section className="studio-card wide-card"><SectionBar title="功能开关" detail="系统范围,变更立即生效" /><div className="feature-flag-grid">{config.featureFlags.map((flag) => <div key={flag.key}><div><strong>{flag.label}</strong><span>{flag.description}</span></div><button type="button" className={`switch-control ${flag.enabled ? "on" : ""}`} onClick={() => toggleFlag(flag)}><span />{flag.enabled ? "启用" : "关闭"}</button></div>)}</div></section>}
|
||||
{(section === "overview" || section === "features") && <section className="studio-card wide-card system-plan-management-card">
|
||||
<SectionBar title="商业套餐模板" detail={`${config.planTemplates?.length || 0} 个模板 · 系统级商品配置`} action={<button className="subtle" type="button" onClick={() => setPlanTemplateForm(emptyPlanTemplateForm())}><Plus size={14} />新建模板</button>} />
|
||||
<div className="plan-template-strip system-plan-template-strip">{(config.planTemplates || []).map((plan) => <div key={plan.id} className={planTemplateForm.id === plan.id ? "selected" : ""}><div><strong>{plan.name}</strong><StatusBadge status={plan.status === "archived" ? "disabled" : "active"} label={plan.status === "archived" ? "已归档" : plan.billingCycle} /></div><span>{plan.seatLimit} 席 · {plan.storageGb}GB · {plan.monthlyClipQuota} clips/月</span><small>{plan.description}</small><div className="plan-template-actions"><button className="icon-text-button" type="button" onClick={() => setPlanTemplateForm(planTemplateFormFromPlan(plan))}><Pencil size={13} />编辑</button><button className="icon-text-button" type="button" onClick={() => archivePlanTemplate(plan)} disabled={planTemplateBusy === plan.id}><Ban size={13} />{plan.status === "archived" ? "恢复" : "归档"}</button></div></div>)}</div>
|
||||
<form className="commercial-plan-form system-plan-template-form" onSubmit={savePlanTemplate}>
|
||||
<label>Tier Key<input value={planTemplateForm.tierKey} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, tierKey: event.target.value })} placeholder="studio-local" required /></label>
|
||||
<label>套餐名称<input value={planTemplateForm.name} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, name: event.target.value })} required /></label>
|
||||
<label>状态<select value={planTemplateForm.status} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, status: event.target.value })}><option value="active">active</option><option value="archived">archived</option></select></label>
|
||||
<label>账单周期<select value={planTemplateForm.billingCycle} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, billingCycle: event.target.value })}><option value="monthly">monthly</option><option value="quarterly">quarterly</option><option value="annual">annual</option></select></label>
|
||||
<label>货币<input maxLength="3" value={planTemplateForm.currency} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, currency: event.target.value.toUpperCase() })} /></label>
|
||||
<label>固定费<input type="number" min="0" step="0.01" value={planTemplateForm.baseFee} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, baseFee: event.target.value })} /></label>
|
||||
<label>席位上限<input type="number" min="1" value={planTemplateForm.seatLimit} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, seatLimit: event.target.value })} /></label>
|
||||
<label>存储 GB<input type="number" min="1" value={planTemplateForm.storageGb} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, storageGb: event.target.value })} /></label>
|
||||
<label>月度片段<input type="number" min="1" value={planTemplateForm.monthlyClipQuota} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, monthlyClipQuota: event.target.value })} /></label>
|
||||
<label>支持 SLA<input value={planTemplateForm.supportSla} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, supportSla: event.target.value })} /></label>
|
||||
<label className="plan-template-description">描述<textarea rows="3" value={planTemplateForm.description} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, description: event.target.value })} /></label>
|
||||
<label className="plan-template-json">limits JSON<textarea rows="6" value={planTemplateForm.limitsJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, limitsJson: event.target.value })} /></label>
|
||||
<label className="plan-template-json">features JSON<textarea rows="6" value={planTemplateForm.featuresJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, featuresJson: event.target.value })} /></label>
|
||||
<label className="plan-template-json">connectorPolicy JSON<textarea rows="6" value={planTemplateForm.connectorPolicyJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, connectorPolicyJson: event.target.value })} /></label>
|
||||
<div className="approval-policy-note"><ShieldCheck size={15} /><span>模板只定义商品与默认能力,不会直接改某个组织;组织页仍通过套餐策略和权益覆盖生效。</span></div>
|
||||
<div className="form-actions"><button className="primary" type="submit" disabled={planTemplateBusy === "save"}><Save size={15} />{planTemplateBusy === "save" ? "保存中…" : planTemplateForm.id ? "保存模板" : "创建模板"}</button></div>
|
||||
</form>
|
||||
</section>}
|
||||
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="真实存储用量" detail={storage ? `当前项目 ${storage.percent}%` : "读取中"} /><div className="storage-admin-grid"><div className="storage-admin-kpi"><HardDrive size={20} /><strong>{storage ? `${storage.usedGb} GB` : "—"}</strong><span>已使用 / {storage?.limitGb || 0} GB</span></div><div className="storage-admin-track"><div><span>文件系统测量</span><b>{storage ? `${storage.usedBytes.toLocaleString()} / ${storage.limitBytes.toLocaleString()} bytes` : "—"}</b></div><div className="quota-track"><span style={{ width: `${storage?.percent || 0}%` }} /></div><small>资产、任务结果和本地合成输出均计入存储配额。</small></div><div className="storage-admin-files"><span>最大文件</span>{storage?.largestFiles?.slice(0, 4).map((file) => <div key={file.path}><strong>{file.relativePath}</strong><em>{(file.bytes / 1024 ** 2).toFixed(2)} MB</em></div>) || <small>暂无文件</small>}</div></div></section>}
|
||||
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="临时媒体治理" detail={cleanup ? `保留 ${cleanup.policy.retentionDays} 天 · ${cleanup.candidates.length} 个候选` : "只处理无引用临时文件"} action={<div className="header-actions"><button className="subtle" onClick={previewCleanup} disabled={cleanupBusy}><RefreshCw size={14} />{cleanupBusy ? "检查中…" : "生成清理预览"}</button>{cleanup?.candidates?.length > 0 && <button className="danger-button" onClick={reclaimCleanup} disabled={cleanupBusy}><Trash2 size={14} />回收候选文件</button>}</div>} /><div className="storage-cleanup-summary"><div><strong>{cleanup?.candidates?.length || 0}</strong><span>无引用临时文件</span></div><div><strong>{((cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB</strong><span>预计可释放</span></div><div><strong>{cleanup?.policy?.provider || "local-filesystem"}</strong><span>存储提供方</span></div><div><strong>{cleanup?.cutoff ? cleanup.cutoff.slice(0, 10) : "—"}</strong><span>本次截止日期</span></div></div>{cleanup?.candidates?.length > 0 ? <div className="storage-cleanup-list">{cleanup.candidates.slice(0, 8).map((item) => <div key={item.relativePath}><span className="mono">{item.relativePath}</span><strong>{(item.bytes / 1024 ** 2).toFixed(2)} MB</strong><small>{item.reason} · {item.updatedAt?.replace("T", " ").slice(0, 19)}</small></div>)}{cleanup.candidates.length > 8 && <small className="muted-copy">还有 {cleanup.candidates.length - 8} 个候选文件,执行时会按服务端预览结果处理。</small>}</div> : <div className="empty-table">没有满足保留周期且未被生产证据引用的临时文件。先调整“存储 · storage.retention_days”后重新生成预览。</div>}</section>}
|
||||
{(section === "overview" || section === "deployment" || section === "storage") && <section className="studio-card wide-card"><SectionBar title="数据库快照备份" detail={`${readiness.backups?.count || 0} 个本地快照`} action={<button className="subtle" onClick={createBackup} disabled={backupBusy}><Database size={14} />{backupBusy ? "创建中…" : "立即创建快照"}</button>} /><div className="backup-summary-row"><div><Database size={18} /><strong>当前业务真源:SQLite</strong><span>快照写入 `data/backups/`,创建动作进入审计日志。</span></div><StatusBadge status={readiness.backups?.count ? "ready" : "needs-evidence"} label={readiness.backups?.count ? "已有备份" : "待创建"} /></div><div className="backup-list">{(readiness.backups?.backups || []).slice(0, 6).map((backup) => <div className="backup-row" key={backup.relativePath}><div><strong>{backup.relativePath}</strong><span>{formatAdminDate(backup.modifiedAt)} · {(Number(backup.bytes || 0) / 1024 ** 2).toFixed(2)} MB</span></div><StatusBadge status="active" label="可用" /></div>)}{!(readiness.backups?.backups || []).length && <div className="empty-table">还没有数据库快照。</div>}</div></section>}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
BookOpenText,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Download,
|
||||
FileCheck2,
|
||||
FileSearch,
|
||||
History,
|
||||
Layers3,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Tags,
|
||||
WandSparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
archiveKnowledgeLibraryDocument,
|
||||
createJob,
|
||||
createKnowledgeContextPack,
|
||||
fetchKnowledgeContextPacks,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeLibrary,
|
||||
fetchKnowledgeLibraryDocumentVersions,
|
||||
importKnowledgeLibraryDocument,
|
||||
materializeKnowledgeContextPack,
|
||||
materializeKnowledgeLibraryDocument,
|
||||
previewJob,
|
||||
restoreKnowledgeLibraryDocument,
|
||||
restoreKnowledgeLibraryDocumentVersion,
|
||||
reviewKnowledgeLibraryDocument,
|
||||
searchKnowledgeLibrary,
|
||||
updateKnowledgeLibraryDocument
|
||||
} from "../lib/api";
|
||||
|
||||
function SuiteHeader({ kicker, title, description, actions }) {
|
||||
return (
|
||||
<div className="suite-header">
|
||||
<div>
|
||||
<span className="card-kicker">{kicker}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="suite-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteMetric({ icon: Icon, label, value, detail, tone = "neutral" }) {
|
||||
return (
|
||||
<div className={`suite-metric ${tone}`}>
|
||||
<div className="suite-metric-icon"><Icon size={17} /></div>
|
||||
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteStatus({ status }) {
|
||||
const config = {
|
||||
indexed: ["已建索引", "ok"],
|
||||
ingested: ["已导入", "neutral"],
|
||||
draft: ["草稿", "neutral"],
|
||||
active: ["可复用", "ok"],
|
||||
archived: ["已归档", "neutral"],
|
||||
workspace: ["工作区级", "ok"],
|
||||
project: ["项目级", "warn"],
|
||||
approved: ["商用已批", "ok"],
|
||||
submitted: ["证据待审", "warn"],
|
||||
"needs-evidence": ["缺授权证据", "warn"],
|
||||
rejected: ["已驳回", "danger"],
|
||||
expired: ["授权过期", "danger"],
|
||||
pass: ["风险通过", "ok"],
|
||||
review: ["需复核", "warn"],
|
||||
warn: ["提示风险", "warn"],
|
||||
blocked: ["阻断", "danger"],
|
||||
unscanned: ["未扫描", "neutral"]
|
||||
}[status] || [status || "未设置", "neutral"];
|
||||
return <span className={`suite-status ${config[1]}`}><span />{config[0]}</span>;
|
||||
}
|
||||
|
||||
const rightsOptions = [
|
||||
["needs-evidence", "缺授权证据"],
|
||||
["submitted", "证据待审核"],
|
||||
["approved", "商用已批准"],
|
||||
["rejected", "已驳回"],
|
||||
["expired", "授权过期"]
|
||||
];
|
||||
|
||||
function provenanceFromDraft(draft) {
|
||||
return {
|
||||
sourceLabel: draft.provenance?.sourceLabel || "",
|
||||
author: draft.provenance?.author || "",
|
||||
rightsOwner: draft.provenance?.rightsOwner || "",
|
||||
evidenceRef: draft.provenance?.evidenceRef || "",
|
||||
licenseNote: draft.provenance?.licenseNote || "",
|
||||
sourceUrl: draft.provenance?.sourceUrl || "",
|
||||
importedFrom: draft.provenance?.importedFrom || "manual"
|
||||
};
|
||||
}
|
||||
|
||||
function provenanceFromDocument(document) {
|
||||
return {
|
||||
sourceLabel: document?.provenance?.sourceLabel || "",
|
||||
author: document?.provenance?.author || "",
|
||||
rightsOwner: document?.provenance?.rightsOwner || "",
|
||||
evidenceRef: document?.provenance?.evidenceRef || "",
|
||||
licenseNote: document?.provenance?.licenseNote || "",
|
||||
sourceUrl: document?.provenance?.sourceUrl || "",
|
||||
importedFrom: document?.provenance?.importedFrom || "manual"
|
||||
};
|
||||
}
|
||||
|
||||
function dedupe(values) {
|
||||
return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function entityList(document, key) {
|
||||
const fromAnalysis = document?.analysis?.entities?.[key] || [];
|
||||
const fromChunks = (document?.chunks || []).flatMap((chunk) => chunk.entities?.[key] || []);
|
||||
return dedupe([...fromAnalysis, ...fromChunks]);
|
||||
}
|
||||
|
||||
function downloadJson(filename, payload) {
|
||||
const blob = new Blob([JSON.stringify(payload || {}, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const defaultDraft = {
|
||||
title: "《雾港来信》小说原稿",
|
||||
sourceType: "novel",
|
||||
scopeMode: "workspace",
|
||||
rightsStatus: "needs-evidence",
|
||||
provenance: {
|
||||
sourceLabel: "原创小说导入",
|
||||
author: "",
|
||||
rightsOwner: "",
|
||||
evidenceRef: "",
|
||||
licenseNote: "未填写授权证明前,只允许作为待审核素材。",
|
||||
sourceUrl: "",
|
||||
importedFrom: "manual"
|
||||
},
|
||||
tags: "原创,待审核,小说",
|
||||
content: [
|
||||
"第一章 夜航",
|
||||
"海雾刚压到码头上,顾临舟就看见灯塔下的白色信封。信封背面只有一行字:不要让任何人知道你今晚见过林栀。",
|
||||
"",
|
||||
"顾临舟:你不是说已经离开雾港了吗?",
|
||||
"林栀:我回来只做一件事,把当年的录音带找出来。",
|
||||
"",
|
||||
"第二章 空仓库",
|
||||
"他们沿着生锈的货轨走进旧仓库,风把卷帘门吹得作响。顾临舟意识到,仓库里提前有人来过,脚印还没被潮气完全抹掉。"
|
||||
].join("\n")
|
||||
};
|
||||
|
||||
const sourceTypes = [
|
||||
["all", "全部来源"],
|
||||
["novel", "小说"],
|
||||
["script", "剧本"],
|
||||
["worldbook", "世界观"],
|
||||
["character-bible", "人物设定"],
|
||||
["reference", "文字参考"]
|
||||
];
|
||||
|
||||
export function KnowledgeLibraryPage({ platformContext, contextOverrides }) {
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canEditKnowledge = permissions.has("script:edit");
|
||||
const canManageCompliance = permissions.has("compliance:manage");
|
||||
const [library, setLibrary] = useState({ documents: [], summary: {} });
|
||||
const [packs, setPacks] = useState({ packs: [], summary: {} });
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [draft, setDraft] = useState(defaultDraft);
|
||||
const [editDraft, setEditDraft] = useState(null);
|
||||
const [reviewDraft, setReviewDraft] = useState({
|
||||
decision: "submitted",
|
||||
rightsStatus: "submitted",
|
||||
sourceLabel: "",
|
||||
author: "",
|
||||
rightsOwner: "",
|
||||
evidenceRef: "",
|
||||
licenseNote: "",
|
||||
sourceUrl: "",
|
||||
tags: "",
|
||||
notes: ""
|
||||
});
|
||||
const [selectedChunkIds, setSelectedChunkIds] = useState([]);
|
||||
const [searchForm, setSearchForm] = useState({ query: "蓝伞 地铁口", sourceType: "all", scopeMode: "all", limit: 12 });
|
||||
const [searchResult, setSearchResult] = useState({ results: [], summary: {} });
|
||||
const [selectedSearchChunkIds, setSelectedSearchChunkIds] = useState([]);
|
||||
const [activePack, setActivePack] = useState(null);
|
||||
const [packBudget, setPackBudget] = useState("1200");
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [jobPreview, setJobPreview] = useState(null);
|
||||
|
||||
async function loadLibrary(nextSelectedId = "") {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchKnowledgeLibrary(contextOverrides);
|
||||
setLibrary(result);
|
||||
const preferredId = nextSelectedId || selectedId || result.documents?.[0]?.id || "";
|
||||
setSelectedId(preferredId);
|
||||
if (!nextSelectedId && !selectedId && result.documents?.[0]?.id) setNotice("");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPacks() {
|
||||
try {
|
||||
const result = await fetchKnowledgeContextPacks(contextOverrides);
|
||||
setPacks(result);
|
||||
setActivePack((current) => current || result.packs?.[0] || null);
|
||||
} catch {
|
||||
setPacks({ packs: [], summary: {} });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(documentId) {
|
||||
if (!documentId) {
|
||||
setDetail(null);
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const result = await fetchKnowledgeDocument(documentId, contextOverrides);
|
||||
setDetail(result.document);
|
||||
setSelectedChunkIds((result.document.chunks || []).map((chunk) => chunk.id));
|
||||
setEditDraft({
|
||||
title: result.document.title || "",
|
||||
sourceType: result.document.sourceType || result.document.source_type || "novel",
|
||||
content: result.document.content || ""
|
||||
});
|
||||
const provenance = provenanceFromDocument(result.document);
|
||||
setReviewDraft({
|
||||
decision: "submitted",
|
||||
rightsStatus: result.document.rightsStatus || "needs-evidence",
|
||||
sourceLabel: provenance.sourceLabel,
|
||||
author: provenance.author,
|
||||
rightsOwner: provenance.rightsOwner,
|
||||
evidenceRef: provenance.evidenceRef,
|
||||
licenseNote: provenance.licenseNote,
|
||||
sourceUrl: provenance.sourceUrl,
|
||||
tags: (result.document.tags || []).join(","),
|
||||
notes: result.document.latestReview?.notes || ""
|
||||
});
|
||||
const versionResult = await fetchKnowledgeLibraryDocumentVersions(documentId, contextOverrides);
|
||||
setVersions(versionResult.versions || []);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKnowledgeSearch(nextForm = searchForm, { preselect = true } = {}) {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const result = await searchKnowledgeLibrary(nextForm, contextOverrides);
|
||||
setSearchResult(result);
|
||||
if (preselect) setSelectedSearchChunkIds((result.results || []).slice(0, 3).map((item) => item.id));
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLibrary();
|
||||
loadPacks();
|
||||
runKnowledgeSearch(searchForm);
|
||||
}, [contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadDetail(selectedId);
|
||||
else setDetail(null);
|
||||
}, [selectedId]);
|
||||
|
||||
const filteredDocuments = useMemo(() => (library.documents || []).filter((item) => `${item.title} ${item.source_type} ${item.sourceType} ${item.summary} ${item.rightsStatus} ${item.riskStatus} ${(item.tags || []).join(" ")} ${item.provenance?.sourceLabel || ""}`.toLowerCase().includes(query.toLowerCase())), [library.documents, query]);
|
||||
const characters = entityList(detail, "characters");
|
||||
const locations = entityList(detail, "locations");
|
||||
const props = entityList(detail, "props");
|
||||
const selectedPackChunkIds = selectedSearchChunkIds.length ? selectedSearchChunkIds : selectedChunkIds;
|
||||
|
||||
async function submitImport() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importKnowledgeLibraryDocument({
|
||||
...draft,
|
||||
provenance: provenanceFromDraft(draft),
|
||||
tags: draft.tags
|
||||
}, contextOverrides);
|
||||
setNotice(`已导入《${result.document.title}》,生成 ${result.document.chunkCount} 个知识片段。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch({ ...searchForm, query: result.document.title }, { preselect: true });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraftProvenance(patch) {
|
||||
setDraft((current) => ({ ...current, provenance: { ...(current.provenance || {}), ...patch } }));
|
||||
}
|
||||
|
||||
function reviewPayload(decision = reviewDraft.decision) {
|
||||
const rightsStatus = decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : reviewDraft.rightsStatus;
|
||||
return {
|
||||
decision,
|
||||
rightsStatus,
|
||||
tags: reviewDraft.tags,
|
||||
notes: reviewDraft.notes,
|
||||
provenance: {
|
||||
sourceLabel: reviewDraft.sourceLabel,
|
||||
author: reviewDraft.author,
|
||||
rightsOwner: reviewDraft.rightsOwner,
|
||||
evidenceRef: reviewDraft.evidenceRef,
|
||||
licenseNote: reviewDraft.licenseNote,
|
||||
sourceUrl: reviewDraft.sourceUrl,
|
||||
importedFrom: "manual"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDocumentUpdate() {
|
||||
if (!detail?.id || !editDraft) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await updateKnowledgeLibraryDocument(detail.id, {
|
||||
...editDraft,
|
||||
rightsStatus: reviewDraft.rightsStatus,
|
||||
tags: reviewDraft.tags,
|
||||
provenance: reviewPayload().provenance
|
||||
}, contextOverrides);
|
||||
setNotice(`已保存《${result.document.title}》为 v${result.document.currentVersionNumber},并重新扫描治理风险。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitGovernance(decision) {
|
||||
if (!detail?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await reviewKnowledgeLibraryDocument(detail.id, reviewPayload(decision), contextOverrides);
|
||||
setNotice(decision === "approved" ? "素材已批准商用,审批记录已写入审计链。" : decision === "rejected" ? "素材已驳回,生产入口会阻断该素材。" : "素材证据已提交审核。");
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchiveDocument() {
|
||||
if (!detail?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = detail.status === "archived"
|
||||
? await restoreKnowledgeLibraryDocument(detail.id, contextOverrides)
|
||||
: await archiveKnowledgeLibraryDocument(detail.id, contextOverrides);
|
||||
setNotice(result.document.status === "archived" ? "素材已归档,检索与引用包创建会隐藏它。" : "素材已恢复索引,可重新检索和引用。");
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch(searchForm, { preselect: false });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion(versionKey) {
|
||||
if (!detail?.id || !versionKey) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await restoreKnowledgeLibraryDocumentVersion(detail.id, versionKey, contextOverrides);
|
||||
setNotice(`已从历史版本恢复,并生成新的当前版本 v${result.document.currentVersionNumber}。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch({ ...searchForm, query: result.document.title }, { preselect: false });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeSelected(pack = activePack) {
|
||||
const chunkIds = pack?.chunkIds?.length ? pack.chunkIds : selectedChunkIds;
|
||||
if (!detail?.id && !chunkIds.length) {
|
||||
setNotice("请先选择知识片段或上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = pack?.id
|
||||
? await materializeKnowledgeContextPack(pack.id, { title: `${pack.name || "知识库上下文包"} · 剧本草稿` }, contextOverrides)
|
||||
: await materializeKnowledgeLibraryDocument(detail?.id || "", {
|
||||
title: `${detail?.title || "知识库素材"} · 剧本草稿`,
|
||||
chunkIds
|
||||
}, contextOverrides);
|
||||
setNotice(`已送入剧本工厂,生成剧本版本 ${result.importedScript?.version_number || ""}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildContextPack() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await createKnowledgeContextPack({
|
||||
name: searchForm.query ? `检索包:${searchForm.query}` : `${detail?.title || "选中素材"} · 上下文包`,
|
||||
query: searchForm.query,
|
||||
sourceType: searchForm.sourceType === "all" ? "mixed" : searchForm.sourceType,
|
||||
scopeMode: searchForm.scopeMode === "project" ? "project" : "workspace",
|
||||
maxTokens: Number(packBudget || 1200),
|
||||
chunkIds: selectedPackChunkIds
|
||||
}, contextOverrides);
|
||||
setActivePack(result.pack);
|
||||
setPacks({ packs: result.packs || [result.pack, ...(packs.packs || [])], summary: result.summary || packs.summary });
|
||||
setNotice(`已创建上下文包“${result.pack.name}”,包含 ${result.pack.selectedCount} 个片段、约 ${result.pack.tokenEstimate} tokens。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function previewScriptJob() {
|
||||
if (!activePack?.id) {
|
||||
setNotice("请先创建或选择一个上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await previewJob({
|
||||
kind: "场景草稿",
|
||||
workflowKey: "script-pipeline",
|
||||
operationKey: "scene-draft",
|
||||
adapter: "owned-model-platform",
|
||||
knowledgePackId: activePack.id,
|
||||
inputs: { prompt: "基于知识库上下文包生成 5-10 秒连续短剧镜头草稿,保留引用证据。" }
|
||||
}, contextOverrides);
|
||||
setJobPreview(result.preview);
|
||||
setNotice("已生成剧本拆解任务预览,引用包已写入请求合同。");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createScriptJob() {
|
||||
if (!activePack?.id) {
|
||||
setNotice("请先创建或选择一个上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await createJob({
|
||||
kind: "场景草稿",
|
||||
workflowKey: "script-pipeline",
|
||||
operationKey: "scene-draft",
|
||||
adapter: "owned-model-platform",
|
||||
knowledgePackId: activePack.id,
|
||||
inputs: { prompt: "基于知识库上下文包生成 5-10 秒连续短剧镜头草稿,保留引用证据。" }
|
||||
}, contextOverrides);
|
||||
setJobPreview(result.job?.request || null);
|
||||
setNotice(`已创建剧本拆解任务:${result.job?.id || ""}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChunk(chunkId) {
|
||||
setSelectedChunkIds((current) => current.includes(chunkId) ? current.filter((item) => item !== chunkId) : [...current, chunkId]);
|
||||
}
|
||||
|
||||
function toggleSearchChunk(chunkId) {
|
||||
setSelectedSearchChunkIds((current) => current.includes(chunkId) ? current.filter((item) => item !== chunkId) : [...current, chunkId]);
|
||||
}
|
||||
|
||||
function updateSearchForm(patch) {
|
||||
setSearchForm((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="production-page">
|
||||
<SuiteHeader
|
||||
kicker="CREATOR SUITE / KNOWLEDGE LIBRARY"
|
||||
title="内容知识库"
|
||||
description="存放小说、剧本原稿、角色设定、世界观和参考文本;导入后自动分块、抽实体、建引用包,并可接入剧本拆解任务。"
|
||||
actions={<>
|
||||
<button className="subtle" onClick={() => { loadLibrary(); loadPacks(); runKnowledgeSearch(searchForm, { preselect: false }); }}><RefreshCw size={15} />刷新</button>
|
||||
<button className="primary" onClick={submitImport} disabled={busy || !canEditKnowledge}><Sparkles size={15} />{busy ? "导入中…" : "导入并分块"}</button>
|
||||
</>}
|
||||
/>
|
||||
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
|
||||
<div className="suite-metric-grid">
|
||||
<SuiteMetric icon={Database} label="知识文档" value={library.summary?.total || 0} detail={`${library.summary?.workspaceScoped || 0} 工作区级 / ${library.summary?.projectScoped || 0} 项目级`} tone="ok" />
|
||||
<SuiteMetric icon={Layers3} label="知识片段" value={library.summary?.chunks || 0} detail="chapter / scene / dialogue / lore" />
|
||||
<SuiteMetric icon={FileCheck2} label="商用已批" value={library.summary?.rightsApproved || 0} detail={`${library.summary?.rightsNeedsEvidence || 0} 份仍需证据`} tone={library.summary?.rightsNeedsEvidence ? "warn" : "ok"} />
|
||||
<SuiteMetric icon={ShieldAlert} label="风险待处理" value={(library.summary?.riskBlocked || 0) + (library.summary?.riskReview || 0)} detail={`${packs.summary?.total || packs.packs?.length || 0} 个上下文包`} tone={(library.summary?.riskBlocked || library.summary?.riskReview) ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="knowledge-library-layout">
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>文档目录</h3><span>{loading ? "读取中…" : `${filteredDocuments.length} / ${library.documents?.length || 0} 份素材`}</span></div>
|
||||
</div>
|
||||
<div className="search-field">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、来源或摘要" />
|
||||
</div>
|
||||
<div className="knowledge-doc-list">
|
||||
{filteredDocuments.map((document) => (
|
||||
<button key={document.id} type="button" className={`knowledge-doc-row ${selectedId === document.id ? "selected" : ""}`} onClick={() => setSelectedId(document.id)}>
|
||||
<div>
|
||||
<strong>{document.title}</strong>
|
||||
<span>{document.source_type} · {document.chunkCount || 0} 个片段</span>
|
||||
</div>
|
||||
<div className="knowledge-doc-statuses">
|
||||
<SuiteStatus status={document.scopeMode} />
|
||||
<SuiteStatus status={document.status} />
|
||||
<SuiteStatus status={document.rightsStatus} />
|
||||
<SuiteStatus status={document.riskStatus} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!filteredDocuments.length && <div className="empty-table">没有匹配的知识文档。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>导入新素材</h3><span>本地规则分块,可后续切换自有模型</span></div>
|
||||
</div>
|
||||
<div className="knowledge-import-form">
|
||||
<label>文档标题<input value={draft.title} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} /></label>
|
||||
<div className="knowledge-import-grid">
|
||||
<label>来源类型<select value={draft.sourceType} onChange={(event) => setDraft((current) => ({ ...current, sourceType: event.target.value }))}>{sourceTypes.filter(([value]) => value !== "all").map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>作用域<select value={draft.scopeMode} onChange={(event) => setDraft((current) => ({ ...current, scopeMode: event.target.value }))}><option value="workspace">工作区复用</option><option value="project">当前项目专用</option></select></label>
|
||||
<label>版权状态<select value={draft.rightsStatus} onChange={(event) => setDraft((current) => ({ ...current, rightsStatus: event.target.value }))}>{rightsOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={draft.provenance?.sourceLabel || ""} onChange={(event) => updateDraftProvenance({ sourceLabel: event.target.value })} placeholder="原创小说 / 已签授权 / 内部资料" /></label>
|
||||
<label>作者<input value={draft.provenance?.author || ""} onChange={(event) => updateDraftProvenance({ author: event.target.value })} /></label>
|
||||
<label>权利人<input value={draft.provenance?.rightsOwner || ""} onChange={(event) => updateDraftProvenance({ rightsOwner: event.target.value })} /></label>
|
||||
<label>证据引用<input value={draft.provenance?.evidenceRef || ""} onChange={(event) => updateDraftProvenance({ evidenceRef: event.target.value })} placeholder="contract:// 或本地授权编号" /></label>
|
||||
<label>标签<input value={draft.tags || ""} onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))} placeholder="原创,待审核,都市悬疑" /></label>
|
||||
</div>
|
||||
<label>许可证/授权备注<textarea rows={3} value={draft.provenance?.licenseNote || ""} onChange={(event) => updateDraftProvenance({ licenseNote: event.target.value })} /></label>
|
||||
<label>素材正文<textarea rows={14} value={draft.content} onChange={(event) => setDraft((current) => ({ ...current, content: event.target.value }))} /></label>
|
||||
<div className="form-actions">
|
||||
<button className="primary" type="button" onClick={submitImport} disabled={busy || !canEditKnowledge}><Sparkles size={15} />导入并切分</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="knowledge-rag-grid">
|
||||
<section className="studio-card wide-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>检索与引用包</h3><span>{searchLoading ? "检索中…" : `${searchResult.total || 0} 个匹配片段`}</span></div>
|
||||
<button className="subtle" type="button" onClick={() => runKnowledgeSearch(searchForm)}><FileSearch size={14} />检索</button>
|
||||
</div>
|
||||
<div className="knowledge-search-controls">
|
||||
<label>检索词<input value={searchForm.query} onChange={(event) => updateSearchForm({ query: event.target.value })} placeholder="例如:蓝伞 地铁口 唐夏" /></label>
|
||||
<label>来源<select value={searchForm.sourceType} onChange={(event) => updateSearchForm({ sourceType: event.target.value })}>{sourceTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>作用域<select value={searchForm.scopeMode} onChange={(event) => updateSearchForm({ scopeMode: event.target.value })}><option value="all">全部可访问</option><option value="workspace">工作区级</option><option value="project">项目级</option></select></label>
|
||||
<label>Token 预算<input type="number" min="100" max="20000" step="100" value={packBudget} onChange={(event) => setPackBudget(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="knowledge-search-results">
|
||||
{(searchResult.results || []).map((result) => (
|
||||
<label key={result.id} className={`knowledge-result-row ${selectedSearchChunkIds.includes(result.id) ? "active" : ""}`}>
|
||||
<input type="checkbox" checked={selectedSearchChunkIds.includes(result.id)} onChange={() => toggleSearchChunk(result.id)} />
|
||||
<span className="knowledge-result-main">
|
||||
<strong>{result.citationKey} · {result.heading || result.documentTitle}</strong>
|
||||
<small>{result.documentTitle} · {result.chunkType} · score {result.score} · {result.rightsStatus || "needs-evidence"} / {result.riskStatus || "unscanned"}</small>
|
||||
<em>{result.snippet}</em>
|
||||
</span>
|
||||
<span className="knowledge-result-meta">~{result.tokenEstimate} tokens</span>
|
||||
</label>
|
||||
))}
|
||||
{!searchResult.results?.length && <div className="empty-table">还没有检索结果。输入关键词后点击检索。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>上下文包</h3><span>{activePack ? activePack.name : "未选择"}</span></div>
|
||||
</div>
|
||||
<div className="knowledge-pack-actions">
|
||||
<button className="primary" type="button" onClick={buildContextPack} disabled={busy || !selectedPackChunkIds.length || !canEditKnowledge}><PackageCheck size={14} />创建引用包</button>
|
||||
<button className="subtle" type="button" onClick={() => materializeSelected(activePack)} disabled={busy || !activePack?.id || !canEditKnowledge}><WandSparkles size={14} />包入剧本</button>
|
||||
<button className="subtle" type="button" onClick={previewScriptJob} disabled={busy || !activePack?.id}><ShieldCheck size={14} />预览任务合同</button>
|
||||
<button className="subtle" type="button" onClick={createScriptJob} disabled={busy || !activePack?.id}><Sparkles size={14} />创建拆解任务</button>
|
||||
</div>
|
||||
<div className="knowledge-pack-list">
|
||||
{(packs.packs || []).map((pack) => (
|
||||
<button key={pack.id} type="button" className={activePack?.id === pack.id ? "selected" : ""} onClick={() => { setActivePack(pack); setJobPreview(null); }}>
|
||||
<strong>{pack.name}</strong>
|
||||
<span>{pack.chunkIds?.length || 0} 个片段 · ~{pack.tokenEstimate || 0} tokens · {pack.governance?.status || "unscanned"}</span>
|
||||
</button>
|
||||
))}
|
||||
{!packs.packs?.length && <div className="empty-table">还没有保存的上下文包。</div>}
|
||||
</div>
|
||||
{activePack && <div className="knowledge-pack-preview">
|
||||
<div className="json-heading">
|
||||
<span>引用预览</span>
|
||||
<button className="icon-only-button" type="button" title="下载上下文包" aria-label="下载上下文包" onClick={() => downloadJson(`${activePack.id}.json`, activePack)}><Download size={14} /></button>
|
||||
</div>
|
||||
<pre>{activePack.promptContext}</pre>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
{jobPreview && <section className="studio-card wide-card knowledge-job-preview">
|
||||
<div className="json-heading">
|
||||
<h3>剧本拆解任务合同预览</h3>
|
||||
<button className="subtle" onClick={() => downloadJson("knowledge-script-job-preview.json", jobPreview)}><Download size={14} />下载合同</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(jobPreview, null, 2)}</pre>
|
||||
</section>}
|
||||
<div className="knowledge-detail-grid">
|
||||
<section className="studio-card wide-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>文档详情</h3><span>{detailLoading ? "读取中…" : (detail?.title || "未选择文档")}</span></div>
|
||||
<div className="header-actions">
|
||||
<button className="subtle" type="button" onClick={() => selectedId && loadDetail(selectedId)} disabled={!selectedId || detailLoading}><RefreshCw size={14} />刷新详情</button>
|
||||
<button className="subtle" type="button" onClick={saveDocumentUpdate} disabled={busy || !detail?.id || !canEditKnowledge}><Save size={14} />保存新版本</button>
|
||||
<button className="subtle" type="button" onClick={toggleArchiveDocument} disabled={busy || !detail?.id || !canEditKnowledge}><Archive size={14} />{detail?.status === "archived" ? "恢复索引" : "归档素材"}</button>
|
||||
<button className="primary" type="button" onClick={() => materializeSelected(null)} disabled={busy || !selectedChunkIds.length || !canEditKnowledge || ["rejected", "expired"].includes(detail?.rightsStatus) || detail?.riskStatus === "blocked"}><WandSparkles size={14} />送入剧本工厂</button>
|
||||
</div>
|
||||
</div>
|
||||
{detail ? (
|
||||
<>
|
||||
<div className="knowledge-summary-grid">
|
||||
<div><span>摘要</span><strong>{detail.summary || "暂无摘要"}</strong></div>
|
||||
<div><span>解析器</span><strong>{detail.analysis?.parser || "local-rule-v2"}</strong></div>
|
||||
<div><span>章节</span><strong>{detail.analysis?.chapterCount || 0}</strong></div>
|
||||
<div><span>片段</span><strong>{detail.chunkCount || 0}</strong></div>
|
||||
<div><span>版权状态</span><strong><SuiteStatus status={detail.rightsStatus} /></strong></div>
|
||||
<div><span>风险状态</span><strong><SuiteStatus status={detail.riskStatus} /></strong></div>
|
||||
<div><span>当前版本</span><strong>v{detail.currentVersionNumber || 1} / {detail.versionCount || versions.length || 1}</strong></div>
|
||||
<div><span>最新审批</span><strong>{detail.latestReview ? `${detail.latestReview.decision} · ${detail.latestReview.createdAt.slice(0, 10)}` : "暂无审批记录"}</strong></div>
|
||||
</div>
|
||||
<div className="knowledge-governance-grid">
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>来源与授权</h3>
|
||||
<SuiteStatus status={reviewDraft.rightsStatus} />
|
||||
</div>
|
||||
<div className="knowledge-governance-form">
|
||||
<label>版权状态<select value={reviewDraft.rightsStatus} onChange={(event) => setReviewDraft((current) => ({ ...current, rightsStatus: event.target.value }))}>{rightsOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={reviewDraft.sourceLabel} onChange={(event) => setReviewDraft((current) => ({ ...current, sourceLabel: event.target.value }))} /></label>
|
||||
<label>作者<input value={reviewDraft.author} onChange={(event) => setReviewDraft((current) => ({ ...current, author: event.target.value }))} /></label>
|
||||
<label>权利人<input value={reviewDraft.rightsOwner} onChange={(event) => setReviewDraft((current) => ({ ...current, rightsOwner: event.target.value }))} /></label>
|
||||
<label>证据引用<input value={reviewDraft.evidenceRef} onChange={(event) => setReviewDraft((current) => ({ ...current, evidenceRef: event.target.value }))} /></label>
|
||||
<label>标签<input value={reviewDraft.tags} onChange={(event) => setReviewDraft((current) => ({ ...current, tags: event.target.value }))} /></label>
|
||||
<label className="wide">来源链接<input value={reviewDraft.sourceUrl} onChange={(event) => setReviewDraft((current) => ({ ...current, sourceUrl: event.target.value }))} /></label>
|
||||
<label className="wide">授权备注<textarea rows={3} value={reviewDraft.licenseNote} onChange={(event) => setReviewDraft((current) => ({ ...current, licenseNote: event.target.value }))} /></label>
|
||||
<label className="wide">审批备注<textarea rows={3} value={reviewDraft.notes} onChange={(event) => setReviewDraft((current) => ({ ...current, notes: event.target.value }))} /></label>
|
||||
</div>
|
||||
<div className="knowledge-governance-actions">
|
||||
<button className="subtle" type="button" onClick={() => submitGovernance("submitted")} disabled={busy || !canEditKnowledge}><FileCheck2 size={14} />提交证据</button>
|
||||
{canManageCompliance && <button className="primary" type="button" onClick={() => submitGovernance("approved")} disabled={busy}><ShieldCheck size={14} />批准商用</button>}
|
||||
{canManageCompliance && <button className="subtle" type="button" onClick={() => submitGovernance("rejected")} disabled={busy}><AlertTriangle size={14} />驳回</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>本地风险扫描</h3>
|
||||
<SuiteStatus status={detail.riskStatus} />
|
||||
</div>
|
||||
<div className="knowledge-risk-score">
|
||||
<ShieldAlert size={18} />
|
||||
<strong>{detail.riskScore || 0}</strong>
|
||||
<span>{detail.risk?.scanner || "local-governance-v1"}</span>
|
||||
</div>
|
||||
<div className="knowledge-risk-list">
|
||||
{(detail.risk?.issues || []).map((issue) => (
|
||||
<div key={`${issue.code}-${issue.message}`}>
|
||||
<SuiteStatus status={issue.severity === "blocking" ? "blocked" : issue.severity === "review" ? "review" : "warn"} />
|
||||
<strong>{issue.code}</strong>
|
||||
<p>{issue.message}</p>
|
||||
{issue.matches?.length ? <span>{issue.matches.join(" / ")}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{!detail.risk?.issues?.length && <div className="empty-table">本地扫描未发现阻断项。</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>版本历史</h3>
|
||||
<History size={15} />
|
||||
</div>
|
||||
<div className="knowledge-version-list">
|
||||
{versions.map((version) => (
|
||||
<button key={version.id} type="button" onClick={() => restoreVersion(version.id)} disabled={busy || !canEditKnowledge}>
|
||||
<span>v{version.versionNumber}</span>
|
||||
<strong>{version.title}</strong>
|
||||
<em>{version.createdAt.slice(0, 16).replace("T", " ")}</em>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
))}
|
||||
{!versions.length && <div className="empty-table">暂无版本快照。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-editor-panel">
|
||||
<div className="json-heading">
|
||||
<h3>素材正文编辑</h3>
|
||||
<button className="subtle" type="button" onClick={saveDocumentUpdate} disabled={busy || !canEditKnowledge}><Save size={14} />保存新版本</button>
|
||||
</div>
|
||||
<div className="knowledge-editor-grid">
|
||||
<label>标题<input value={editDraft?.title || ""} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), title: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={editDraft?.sourceType || "novel"} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), sourceType: event.target.value }))}>{sourceTypes.filter(([value]) => value !== "all").map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label className="wide">正文<textarea rows={8} value={editDraft?.content || ""} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), content: event.target.value }))} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-entity-columns">
|
||||
<div><span>角色</span><div>{characters.map((item) => <button key={item} type="button">{item}</button>)}{!characters.length && <em>未识别</em>}</div></div>
|
||||
<div><span>场景</span><div>{locations.map((item) => <button key={item} type="button">{item}</button>)}{!locations.length && <em>未识别</em>}</div></div>
|
||||
<div><span>道具</span><div>{props.map((item) => <button key={item} type="button">{item}</button>)}{!props.length && <em>未识别</em>}</div></div>
|
||||
</div>
|
||||
<div className="knowledge-chunk-list">
|
||||
{detail.chunks?.map((chunk) => (
|
||||
<div key={chunk.id} className={`knowledge-chunk-card ${selectedChunkIds.includes(chunk.id) ? "active" : ""}`}>
|
||||
<label className="knowledge-chunk-toggle">
|
||||
<input type="checkbox" checked={selectedChunkIds.includes(chunk.id)} onChange={() => toggleChunk(chunk.id)} />
|
||||
<strong>{chunk.heading}</strong>
|
||||
<span>{chunk.chunk_type} · ~{chunk.tokenEstimate} tokens</span>
|
||||
</label>
|
||||
<p>{chunk.content}</p>
|
||||
<div className="knowledge-chunk-statuses">
|
||||
<SuiteStatus status={detail.rightsStatus} />
|
||||
<SuiteStatus status={detail.riskStatus} />
|
||||
</div>
|
||||
<div className="knowledge-chunk-tags">
|
||||
{(chunk.keywords || []).map((keyword) => <span key={keyword}>{keyword}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-table">选择一份素材后,可查看分块、实体和入剧本动作。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+164
@@ -330,6 +330,10 @@ export async function createJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function previewJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs/preview", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssets(overrides = {}) {
|
||||
return apiFetch("/api/assets", {}, overrides);
|
||||
}
|
||||
@@ -358,6 +362,14 @@ export async function verifyAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/verify`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssetGovernanceReviews(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/governance`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function scanAssetGovernance(assetId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/governance/scan`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
@@ -471,6 +483,27 @@ export async function fetchOrganizationCommercial(organizationId, overrides = {}
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationEntitlements(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationEntitlement(organizationId, entitlementKey, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements/${encodeURIComponent(entitlementKey)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchCommercialApprovals(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createCommercialApproval(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideCommercialApproval(organizationId, approvalId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
function usageQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ["query", "workspaceId", "projectId", "userId", "kind", "unitName", "costCenter", "status", "from", "to", "page", "pageSize", "format"]) {
|
||||
@@ -573,6 +606,18 @@ export async function updateFeatureFlag(key, enabled, overrides = {}) {
|
||||
return apiFetch("/api/system/feature-flags", { method: "POST", body: JSON.stringify({ key, enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemPlanTemplates(overrides = {}) {
|
||||
return apiFetch("/api/system/plan-templates", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemPlanTemplate(body, overrides = {}) {
|
||||
return apiFetch("/api/system/plan-templates", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemPlanTemplate(planId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/plan-templates/${encodeURIComponent(planId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(channel, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications", { method: "POST", body: JSON.stringify(channel) }, overrides);
|
||||
}
|
||||
@@ -686,6 +731,71 @@ export async function materializeProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/materialize", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeLibrary(overrides = {}) {
|
||||
return apiFetch("/api/knowledge/library", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function importKnowledgeLibraryDocument(body, overrides = {}) {
|
||||
return apiFetch("/api/knowledge/library/import", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function reviewKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/review`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function archiveKnowledgeLibraryDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/archive`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function restoreKnowledgeLibraryDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeLibraryDocumentVersions(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function restoreKnowledgeLibraryDocumentVersion(documentId, versionKey, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions/${encodeURIComponent(versionKey)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function searchKnowledgeLibrary(params = {}, overrides = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.query) search.set("q", params.query);
|
||||
if (params.sourceType) search.set("sourceType", params.sourceType);
|
||||
if (params.scopeMode) search.set("scopeMode", params.scopeMode);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
return apiFetch(`/api/knowledge/search${search.toString() ? `?${search.toString()}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeContextPacks(overrides = {}) {
|
||||
return apiFetch("/api/knowledge/context-packs", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createKnowledgeContextPack(body, overrides = {}) {
|
||||
return apiFetch("/api/knowledge/context-packs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeContextPack(packId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/context-packs/${encodeURIComponent(packId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function materializeKnowledgeContextPack(packId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/context-packs/${encodeURIComponent(packId)}/materialize`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function materializeKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/materialize`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionBible(body, overrides = {}) {
|
||||
return apiFetch("/api/production/bible", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
@@ -783,6 +893,14 @@ export async function approveDelivery(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryClearance(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function runDeliveryClearance(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryReleases(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {}, overrides);
|
||||
}
|
||||
@@ -902,6 +1020,52 @@ export async function probeModel(modelId, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelCatalog(overrides = {}) {
|
||||
return apiFetch("/api/platform/model-catalog", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createModelCatalog(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-catalog", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModelCatalog(entryId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-catalog/${encodeURIComponent(entryId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelRoutes(overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createModelRoute(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resolveModelRoute(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes/resolve", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelRouteApprovals(filters = {}, overrides = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return apiFetch(`/api/platform/model-route-approvals${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function requestModelRouteApproval(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-route-approvals", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideModelRouteApproval(approvalId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModelRoute(routeId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-routes/${encodeURIComponent(routeId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJobs(overrides = {}) {
|
||||
return apiFetch("/api/jobs", {}, overrides);
|
||||
}
|
||||
|
||||
+1032
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user