大量修改二期功能清单内容

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
@@ -59,7 +59,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
@@ -81,6 +81,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
onClear: () => void;
onSend: () => void;
}) {
@@ -134,6 +135,9 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
<IconKitSvg name="users" size={18} />
</button>
<button type="button" className="fc-add fc-lib" title="引用平台素材(资产库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
<IconKitSvg name="library" size={18} />
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
))}
@@ -0,0 +1,381 @@
// 自由创作·三库引用弹窗(模块4 · 4.1/4.3):资产库 / 模特库 / 商品库 三个 tab 挑素材。
// 三个库挑出来的最终都是一行平台 Asset,所以注入输入条时统一 source=asset,
// 后端 build_content_items 只有一个分支要处理,审核闸也只有一处。
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, Images, Package, Search, ShieldCheck, User, X } from "lucide-react";
import { api } from "../../api";
import type { Asset, FreeVideoRef, ModelEntity, Product } from "../../types";
import { useBodyScrollLock } from "../overlays";
type LibTab = "assets" | "models" | "products";
const TAB_LABEL: Record<LibTab, string> = { assets: "资产库", models: "模特库", products: "商品库" };
const PAGE_SIZE = 24;
/** 引用前的审核判定。与后端 apps/assets/review.py 的 reference_review_state 同一套规则:
* 平台生成的免审,用户上传的必须审过。这里只为提前给个说法,真正拦人的是后端。 */
type RefState = "allowed" | "processing" | "failed" | "unsubmitted";
function refState(asset: Pick<Asset, "source" | "review_status">): RefState {
if (asset.source === "ai_generated" || asset.source === "system") return "allowed";
if (asset.review_status === "active") return "allowed";
if (asset.review_status === "processing") return "processing";
if (asset.review_status === "failed") return "failed";
return "unsubmitted";
}
const STATE_PILL: Record<Exclude<RefState, "allowed">, { cls: string; label: string }> = {
processing: { cls: "pill-info", label: "审核中" },
failed: { cls: "pill-err", label: "未通过" },
unsubmitted: { cls: "pill-warn", label: "待送审" }
};
function assetThumb(asset: Asset): string {
const primary = asset.files?.find((f) => f.is_primary) || asset.files?.[0];
return primary?.preview_url || "";
}
export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify }: {
open: boolean;
/** 首尾帧模式只能用图片 */
imageOnly: boolean;
onClose: () => void;
onPick: (ref: FreeVideoRef) => void;
notify: (type: "success" | "error" | "info", text: string) => void;
}) {
const [tab, setTab] = useState<LibTab>("assets");
const [queryInput, setQueryInput] = useState("");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [assets, setAssets] = useState<Asset[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [models, setModels] = useState<ModelEntity[]>([]);
const [products, setProducts] = useState<Product[]>([]);
// 商品库是两层的:先选商品,再从它的图里挑一张
const [openProduct, setOpenProduct] = useState<Product | null>(null);
const [productAssets, setProductAssets] = useState<Asset[]>([]);
const [submittingReview, setSubmittingReview] = useState<string | null>(null);
useBodyScrollLock(open);
const resetView = useCallback(() => {
setAssets([]);
setModels([]);
setProducts([]);
setOpenProduct(null);
setProductAssets([]);
setPage(1);
setHasMore(false);
}, []);
useEffect(() => {
if (!open) return;
setTab("assets");
setQueryInput("");
setQuery("");
resetView();
}, [open, resetView]);
// 搜索框防抖:不防抖会一个字一次请求,列表还会边打边跳
useEffect(() => {
const timer = window.setTimeout(() => setQuery(queryInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [queryInput]);
const loadAssets = useCallback(async (nextPage: number) => {
setLoading(true);
try {
const data = await api.assetsPage({
q: query || undefined,
asset_type: imageOnly ? "image" : undefined,
page: nextPage,
pageSize: PAGE_SIZE
});
setAssets((prev) => (nextPage === 1 ? data.results : [...prev, ...data.results]));
setPage(nextPage);
setHasMore(Boolean(data.next));
} catch (error) {
notify("error", error instanceof Error ? error.message : "资产加载失败");
} finally {
setLoading(false);
}
}, [query, imageOnly, notify]);
const loadModels = useCallback(async () => {
setLoading(true);
try {
const data = await api.listModels({ q: query || undefined });
setModels(data.results);
} catch (error) {
notify("error", error instanceof Error ? error.message : "模特库加载失败");
} finally {
setLoading(false);
}
}, [query, notify]);
const loadProducts = useCallback(async () => {
setLoading(true);
try {
const data = await api.products();
const keyword = query.trim().toLowerCase();
setProducts(keyword
? data.results.filter((p) => `${p.title} ${p.brand} ${p.category}`.toLowerCase().includes(keyword))
: data.results);
} catch (error) {
notify("error", error instanceof Error ? error.message : "商品库加载失败");
} finally {
setLoading(false);
}
}, [query, notify]);
useEffect(() => {
if (!open) return;
resetView();
if (tab === "assets") void loadAssets(1);
else if (tab === "models") void loadModels();
else void loadProducts();
// query 变化也要重拉,所以它在依赖里
}, [open, tab, loadAssets, loadModels, loadProducts, resetView]);
// 商品下钻:把它的封面图 + 素材图取成一组资产
const openProductDetail = useCallback(async (product: Product) => {
setOpenProduct(product);
setLoading(true);
try {
const data = await api.assetsPage({ product: product.id, asset_type: imageOnly ? "image" : undefined, pageSize: 100 });
setProductAssets(data.results);
} catch (error) {
notify("error", error instanceof Error ? error.message : "商品素材加载失败");
} finally {
setLoading(false);
}
}, [imageOnly, notify]);
const pickAsset = useCallback((asset: Asset, labelHint?: string) => {
const state = refState(asset);
if (state === "processing") { notify("info", "这个素材还在审核中,通过后才能引用"); return; }
if (state === "failed") { notify("error", asset.review_error || "这个素材未通过审核,不能引用"); return; }
if (state === "unsubmitted") { notify("info", "上传素材要先审核通过才能引用,点卡片上的「送审」提交"); return; }
const kind = asset.asset_type === "video" ? "video" : asset.asset_type === "audio" ? "audio" : "image";
if (imageOnly && kind !== "image") { notify("error", "首尾帧仅支持图片素材"); return; }
const url = assetThumb(asset);
if (!url) { notify("error", "这个素材没有可用文件"); return; }
onPick({
url,
type: kind,
label: (labelHint || asset.display_name || asset.name || "素材").slice(0, 24),
thumb_url: kind === "image" ? url : undefined,
asset_id: asset.id,
source: "asset"
});
onClose();
}, [imageOnly, onPick, onClose, notify]);
const sendToReview = useCallback(async (asset: Asset) => {
setSubmittingReview(asset.id);
try {
const data = await api.submitAssetReview(asset.id, true);
const patch = (list: Asset[]) => list.map((a) => (a.id === asset.id ? { ...a, review_status: data.review_status } : a));
setAssets(patch);
setProductAssets(patch);
notify("success", "已提交审核,通过后即可引用");
} catch (error) {
notify("error", error instanceof Error ? error.message : "送审失败");
} finally {
setSubmittingReview(null);
}
}, [notify]);
// 模特一张卡最多两个可引用资产:形象图 + 三视图
const modelAssets = useCallback((model: ModelEntity) => {
const out: Array<{ id: string; url: string; tag: string }> = [];
if (model.portrait_asset && model.portrait) out.push({ id: model.portrait_asset, url: model.portrait, tag: "形象图" });
if (model.triview_asset && model.triview) out.push({ id: model.triview_asset, url: model.triview, tag: "三视图" });
return out;
}, []);
// 模特库的资产是平台生成/上传的 Model 附属资产,拿不到完整 Asset 行 → 交给后端判审核,
// 这里直接按 id 引用,后端 _guard_asset_reference 会给出准确的放行或拒绝理由。
const pickModelAsset = useCallback((model: ModelEntity, item: { id: string; url: string; tag: string }) => {
onPick({
url: item.url,
type: "image",
label: `${model.name}${item.tag === "三视图" ? "三视图" : ""}`.slice(0, 24),
thumb_url: item.url,
asset_id: item.id,
source: "asset"
});
onClose();
}, [onPick, onClose]);
const gridAssets = openProduct ? productAssets : assets;
const emptyText = useMemo(() => {
if (tab === "models") return "还没有模特";
if (tab === "products") return openProduct ? "这个商品还没有素材" : "还没有商品";
return "资产库里还没有可引用的素材";
}, [tab, openProduct]);
if (!open) return null;
const renderAssetCard = (asset: Asset, labelHint?: string) => {
const state = refState(asset);
const pill = state === "allowed" ? null : STATE_PILL[state];
const thumb = assetThumb(asset);
return (
<div
key={asset.id}
className={`fc-lib-asset${state === "allowed" ? " pickable" : ""}`}
role="button"
tabIndex={0}
onClick={() => pickAsset(asset, labelHint)}
onKeyDown={(event) => { if (event.key === "Enter") pickAsset(asset, labelHint); }}
>
<div className="fc-lib-thumb">
{thumb && asset.asset_type === "image"
? <img src={thumb} alt={asset.name} />
: <span className="mono">{asset.asset_type === "audio" ? "♪" : asset.asset_type.toUpperCase()}</span>}
</div>
<div className="fc-lib-name" title={asset.display_name || asset.name}>{asset.display_name || asset.name}</div>
{pill
? <span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
: <span className="fc-lib-count mono">// {asset.source === "upload" ? "已过审" : "平台生成"}</span>}
{state === "unsubmitted" && (
<button
type="button"
className="btn btn-sm btn-ghost fc-lib-review-btn"
disabled={submittingReview === asset.id}
onClick={(event) => { event.stopPropagation(); void sendToReview(asset); }}
>
<ShieldCheck size={12} /> {submittingReview === asset.id ? "提交中…" : "送审"}
</button>
)}
</div>
);
};
return createPortal(
<div className="modal-bg show" onClick={onClose}>
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Images size={16} /></div>
<div className="ti">
{openProduct ? (
<button type="button" className="fc-lib-back" onClick={() => { setOpenProduct(null); setProductAssets([]); }}>
<ChevronLeft size={14} /> {openProduct.title}
</button>
) : "引用平台素材"}
<span>// 资产库 · 模特库 · 商品库</span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b fc-lib-body">
{!openProduct && (
<div className="tabs fc-lib-tabs">
{(Object.keys(TAB_LABEL) as LibTab[]).map((key) => (
<div key={key} className={`tab${tab === key ? " active" : ""}`} role="button" tabIndex={0}
onClick={() => setTab(key)}
onKeyDown={(event) => { if (event.key === "Enter") setTab(key); }}>
{TAB_LABEL[key]}
</div>
))}
</div>
)}
<div className="fc-lib-toolbar">
{/* 商品下钻后列的是这一个商品的素材,搜索框在这层没有意义,收起来免得成死控件 */}
{!openProduct && (
<label className="fc-lib-search">
<Search size={13} />
<input
className="input"
placeholder={`搜索${TAB_LABEL[tab]}`}
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
/>
</label>
)}
<span className="mono fc-lib-hint">
{imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"}
</span>
</div>
{tab === "models" && !openProduct ? (
models.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><User size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 去模特库添加模特后可在这里引用形象图与三视图</p>
</div>
) : (
<div className="fc-lib-assets">
{models.map((model) => {
const items = modelAssets(model);
return (
<div key={model.id} className="fc-lib-asset">
<div className="fc-lib-thumb">
{model.portrait ? <img src={model.portrait} alt={model.name} /> : <User size={20} />}
</div>
<div className="fc-lib-name" title={model.name}>{model.name}</div>
<div className="fc-lib-model-picks">
{items.length === 0
? <span className="fc-lib-count mono">// 还没有可引用的图</span>
: items.map((item) => (
<button key={item.id} type="button" className="btn btn-sm" onClick={() => pickModelAsset(model, item)}>
{item.tag}
</button>
))}
</div>
</div>
);
})}
</div>
)
) : tab === "products" && !openProduct ? (
products.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Package size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 去商品库建商品后可在这里引用它的素材</p>
</div>
) : (
<div className="fc-lib-assets">
{products.map((product) => (
<div key={product.id} className="fc-lib-asset pickable" role="button" tabIndex={0}
onClick={() => void openProductDetail(product)}
onKeyDown={(event) => { if (event.key === "Enter") void openProductDetail(product); }}>
<div className="fc-lib-thumb">
{product.cover_preview_url ? <img src={product.cover_preview_url} alt={product.title} /> : <Package size={20} />}
</div>
<div className="fc-lib-name" title={product.title}>{product.title}</div>
<div className="fc-lib-count mono">// {product.category || "未分类"}</div>
</div>
))}
</div>
)
) : gridAssets.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Images size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 平台生成的素材可直接引用,上传的要先审核通过</p>
</div>
) : (
<>
<div className="fc-lib-assets">
{gridAssets.map((asset) => renderAssetCard(asset, openProduct ? `${openProduct.title}` : undefined))}
</div>
{!openProduct && tab === "assets" && hasMore && (
<div className="fc-lib-more">
<button type="button" className="btn btn-sm" disabled={loading} onClick={() => void loadAssets(page + 1)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body
);
}
+138
View File
@@ -25,3 +25,141 @@
color: var(--black-alpha-56, #8a8a8a);
padding: 4px 0 12px;
}
/* 全局加载面板:用于整页、主要区块与弹窗内容首次加载。 */
.system-loading {
width: 100%;
display: grid;
place-items: center;
padding: 32px 24px;
}
.system-loading--fullscreen { min-height: 100vh; padding: 48px 24px; }
.system-loading--page { min-height: min(480px, 62vh); }
.system-loading--inline { min-height: 200px; padding: 24px 16px; }
.system-loading-card {
position: relative;
width: min(440px, 100%);
padding: 28px;
background: var(--surface);
border-radius: var(--r-md);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.system-loading--inline .system-loading-card { width: min(400px, 100%); padding: 24px; }
.system-loading-corner {
position: absolute;
z-index: 1;
width: 14px;
height: 14px;
display: grid;
place-items: center;
color: var(--black-alpha-24);
background: var(--background-base);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
}
.system-loading-corner.corner-tl { top: -7px; left: -7px; }
.system-loading-corner.corner-tr { top: -7px; right: -7px; }
.system-loading-corner.corner-bl { bottom: -7px; left: -7px; }
.system-loading-corner.corner-br { right: -7px; bottom: -7px; }
.system-loading-meta,
.system-loading-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--black-alpha-48);
font-size: 10.5px;
letter-spacing: .05em;
white-space: nowrap;
}
.system-loading-meta span:last-child { color: var(--heat); }
.system-loading-main {
display: flex;
align-items: center;
gap: 18px;
padding: 36px 0 30px;
}
.system-loading--inline .system-loading-main { padding: 28px 0 24px; }
.system-loading-icon {
position: relative;
width: 44px;
height: 44px;
flex: 0 0 44px;
display: grid;
place-items: center;
color: var(--heat);
background: var(--heat-12);
border-radius: var(--r-md);
}
.system-loading-orbit {
position: absolute;
inset: -5px;
border: 1px solid var(--heat-20);
border-top-color: var(--heat);
border-radius: var(--r-md);
animation: system-loading-spin 1.1s linear infinite;
}
.system-loading-copy { min-width: 0; }
.system-loading-copy h2 {
margin: 0;
color: var(--accent-black);
font-size: 18px;
font-weight: 500;
letter-spacing: -.01em;
line-height: 1.4;
}
.system-loading-copy p {
margin: 6px 0 0;
color: var(--black-alpha-56);
font-size: 12.5px;
line-height: 1.7;
}
.system-loading-progress {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
height: 3px;
margin-bottom: 22px;
}
.system-loading-progress span {
border-radius: 2px;
background: var(--black-alpha-8);
animation: system-loading-step 1.5s ease-in-out infinite;
}
.system-loading-progress span:nth-child(2) { animation-delay: .12s; }
.system-loading-progress span:nth-child(3) { animation-delay: .24s; }
.system-loading-progress span:nth-child(4) { animation-delay: .36s; }
.system-loading-progress span:nth-child(5) { animation-delay: .48s; }
.system-loading-foot {
padding-top: 16px;
border-top: 1px solid var(--border-faint);
font-size: 10px;
}
.system-loading-actions {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 22px;
}
.system-loading.is-error .system-loading-meta span:last-child,
.system-loading.is-error .system-loading-icon { color: var(--accent-crimson); }
.system-loading.is-error .system-loading-icon { background: var(--crimson-bg); }
@keyframes system-loading-spin {
to { transform: rotate(360deg); }
}
@keyframes system-loading-step {
0%, 70%, 100% { background: var(--black-alpha-8); }
28% { background: var(--heat); }
}
@media (prefers-reduced-motion: reduce) {
.system-loading-orbit,
.system-loading-progress span { animation: none; }
.system-loading-progress span:first-child { background: var(--heat); }
}
@media (max-width: 520px) {
.system-loading-card { padding: 24px; }
.system-loading-meta span:first-child,
.system-loading-foot span:last-child { display: none; }
}
+60
View File
@@ -1,5 +1,65 @@
import type { ReactNode } from "react";
import { IconKitSvg } from "./IconKitSvg";
import "./loading.css";
export function SystemLoading({
variant = "page",
state = "loading",
title = "正在加载",
description = "正在同步最新数据,请稍候。",
icon = "activity",
reference,
actions,
}: {
variant?: "inline" | "page" | "fullscreen";
state?: "loading" | "error";
title?: string;
description?: string;
icon?: string;
reference?: string;
actions?: ReactNode;
}) {
return (
<div className={`system-loading system-loading--${variant}${state === "error" ? " is-error" : ""}`} role={state === "error" ? "alert" : "status"} aria-live="polite">
<div className="system-loading-card">
<span className="system-loading-corner corner-tl" aria-hidden="true">+</span>
<span className="system-loading-corner corner-tr" aria-hidden="true">+</span>
<span className="system-loading-corner corner-bl" aria-hidden="true">+</span>
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
<div className="system-loading-meta mono">
<span>[ AIRSHELF / SYSTEM ]</span>
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
</div>
<div className="system-loading-main">
<div className="system-loading-icon" aria-hidden="true">
<IconKitSvg name={icon} size={20} />
{state === "loading" && <span className="system-loading-orbit" />}
</div>
<div className="system-loading-copy">
<h2>{title}</h2>
<p>{description}</p>
</div>
</div>
{actions ? (
<div className="system-loading-actions">{actions}</div>
) : state === "loading" ? (
<div className="system-loading-progress" aria-hidden="true">
<span /><span /><span /><span /><span />
</div>
) : null}
<div className="system-loading-foot mono">
<span>{reference ? `// REF · ${reference}` : "// DATA SYNC"}</span>
<span>{state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"}</span>
</div>
</div>
</div>
);
}
// 列表数据在途时的骨架屏(grid 卡片 / 行),替代「为空」状态,让用户看到「加载中」而非「没数据」。
export function SkeletonGrid({ count = 8 }: { count?: number }) {
return (