模特库标签分页与全能创作收口:藏长视频、选择器分页

角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:24:37 +08:00
parent 690eb3d843
commit 0bd1db6bf9
48 changed files with 3386 additions and 896 deletions
@@ -372,3 +372,12 @@
display: none;
}
}
.omni-asset-picker-pager {
margin-top: 14px;
padding-bottom: 2px;
}
.omni-asset-picker-pager .list-pager {
justify-content: center;
flex-wrap: wrap;
}
@@ -11,6 +11,7 @@ import {
X,
} from "lucide-react";
import { api } from "../api";
import { Pager } from "./pager";
import type { CreationRef } from "../types";
import "./asset-select-modal.css";
@@ -24,11 +25,7 @@ interface AssetSelectModalProps {
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}
const assetLibraryCache = new Map<string, CreationRef[]>();
function assetLibraryKey(type: AssetModalType, query: string) {
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
}
const PAGE_SIZE = 20;
export function AssetSelectModal({
open,
@@ -40,7 +37,8 @@ export function AssetSelectModal({
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [items, setItems] = useState<CreationRef[]>([]);
const [loadedKey, setLoadedKey] = useState("");
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
@@ -57,34 +55,31 @@ export function AssetSelectModal({
const titleText = type === "product" ? "商品" : "角色";
const title = type === "product" ? "商品库" : "角色库";
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
const requestKey = assetLibraryKey(type, debouncedQuery);
const cachedItems = assetLibraryCache.get(requestKey);
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
const visibleItems = useMemo(() => {
if (sortOrder === "recent") return currentItems;
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
}, [currentItems, sortOrder]);
if (sortOrder === "recent") return items;
return [...items].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
}, [items, sortOrder]);
useEffect(() => {
if (!open) {
setIsAddingNew(false);
setSearchQuery("");
setDebouncedQuery("");
setSelectedRef(null);
setSortOrder("recent");
setNewFile(null);
setNewPreview("");
setNewName("");
setPage(1);
setItems([]);
setTotal(0);
return;
}
}, [open]);
useEffect(() => {
if (!open) {
setDebouncedQuery("");
return;
}
if (!open) return;
const query = searchQuery.trim();
if (!query) {
setDebouncedQuery("");
@@ -94,45 +89,68 @@ export function AssetSelectModal({
return () => window.clearTimeout(timer);
}, [open, searchQuery]);
// 换库 / 搜索时回到第 1 页
useEffect(() => {
if (!open) return;
setPage(1);
setSelectedRef(null);
}, [type, debouncedQuery]);
const cached = assetLibraryCache.get(requestKey);
if (cached) {
setItems(cached);
setLoadedKey(requestKey);
setLoading(false);
return;
}
useEffect(() => {
if (!open || isAddingNew) return;
let cancelled = false;
setLoading(true);
setSelectedRef(null);
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
void api
.searchMentions({ q: debouncedQuery, types, limit: 20 })
.then((response) => {
if (cancelled) return;
const results = response.results || [];
assetLibraryCache.set(requestKey, results);
setItems(results);
setLoadedKey(requestKey);
})
.catch((error) => {
if (cancelled) return;
assetLibraryCache.set(requestKey, []);
setItems([]);
setLoadedKey(requestKey);
notifyRef.current?.("error", (error as Error).message || "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
const load = async () => {
try {
if (type === "character") {
const response = await api.listModels({
q: debouncedQuery || undefined,
page,
pageSize: PAGE_SIZE,
});
if (cancelled) return;
const mapped: CreationRef[] = (response.results || [])
.filter((m) => m.portrait)
.map((m) => ({
type: "model",
id: m.id,
name: m.name,
cover: m.portrait,
}));
setItems(mapped);
setTotal(response.count ?? mapped.length);
} else {
const response = await api.products({
q: debouncedQuery || undefined,
page,
pageSize: PAGE_SIZE,
});
if (cancelled) return;
const mapped: CreationRef[] = (response.results || []).map((p) => ({
type: "product",
id: p.id,
name: p.title,
cover: p.cover_preview_url || "",
}));
setItems(mapped);
setTotal(response.count ?? mapped.length);
}
} catch (error) {
if (cancelled) return;
setItems([]);
setTotal(0);
notifyRef.current?.("error", (error as Error).message || "加载失败");
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [debouncedQuery, open, requestKey, type]);
}, [debouncedQuery, open, page, type, isAddingNew]);
useEffect(() => {
if (!open) return;
@@ -199,9 +217,6 @@ export function AssetSelectModal({
}
notifyRef.current?.("success", `已新增${titleText}${finalRef.name}`);
for (const key of assetLibraryCache.keys()) {
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
}
onSelect(finalRef);
onClose();
} catch (error) {
@@ -234,7 +249,6 @@ export function AssetSelectModal({
</span>
<div className="ti" id={titleId}>
{isAddingNew ? `新增${titleText}` : title}
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
</div>
<div className="omni-asset-picker-head-actions">
{isAddingNew ? (
@@ -328,8 +342,8 @@ export function AssetSelectModal({
<option value="name"></option>
</select>
</div>
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
{!hasCurrentData ? (
<div className="modal-b omni-asset-picker-body" aria-busy={loading}>
{loading && items.length === 0 ? (
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}`}>
{Array.from({ length: 10 }, (_, index) => (
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
@@ -353,38 +367,42 @@ export function AssetSelectModal({
) : null}
</div>
) : (
<div className="omni-asset-picker-grid">
{visibleItems.map((item) => {
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
const displayName = item.name.split(" · ")[0];
return (
<button
type="button"
key={`${item.type}:${item.id}`}
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => setSelectedRef(item)}
onDoubleClick={() => {
onSelect(item);
onClose();
}}
>
<span className="omni-asset-picker-thumb">
{item.cover ? (
<img src={item.cover} alt="" />
) : (
<span className="omni-asset-picker-fallback">
{type === "product" ? <Box /> : <UserRound />}
</span>
)}
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
</span>
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
</button>
);
})}
</div>
<>
<div className={`omni-asset-picker-grid${loading ? " is-loading" : ""}`}>
{visibleItems.map((item) => {
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
const displayName = item.name.split(" · ")[0];
return (
<button
type="button"
key={`${item.type}:${item.id}`}
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => setSelectedRef(item)}
onDoubleClick={() => {
onSelect(item);
onClose();
}}
>
<span className="omni-asset-picker-thumb">
{item.cover ? (
<img src={item.cover} alt="" />
) : (
<span className="omni-asset-picker-fallback">
{type === "product" ? <Box /> : <UserRound />}
</span>
)}
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
</span>
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
</button>
);
})}
</div>
<div className="omni-asset-picker-pager">
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
</>
)}
</div>
<footer className="modal-f">
+1 -2
View File
@@ -28,8 +28,7 @@ export function SystemLoading({
<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>
<span>{state === "error" ? "连接异常" : "同步中"}</span>
</div>
<div className="system-loading-main">
+38 -6
View File
@@ -18,7 +18,7 @@ const modelToAsset = (m: ModelEntity): Asset => ({
source: m.source === "upload" ? "upload" : "ai_generated",
category: "model_portrait",
description: "",
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official, tags: Array.isArray(m.metadata?.tags) ? m.metadata.tags : [] },
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
created_at: m.created_at,
updated_at: m.updated_at
@@ -61,20 +61,34 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
const [fetched, setFetched] = useState<Asset[]>([]);
const [tagFilter, setTagFilter] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
const reload = useCallback(async () => {
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
const models = await api.listModels({ pageSize: 200, tags: tagFilter }).catch(() => null);
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
setFetched(mapped);
}, []);
}, [tagFilter]);
useEffect(() => { if (open) void reload(); }, [open, reload]);
useEffect(() => {
if (!open) return;
let alive = true;
api.listModelTags()
.then((r) => { if (alive) setTagOptions(r.results || []); })
.catch(() => { if (alive) setTagOptions([]); });
return () => { alive = false; };
}, [open]);
useEffect(() => { if (open) setTagFilter([]); }, [open]);
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
function toggleTag(name: string) {
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
}
// 模特库分页(客户端):一页 10 个5 列 × 2 行)
const PAGE_SIZE = 10;
// 模特库分页(客户端):一页 20 个。
const PAGE_SIZE = 20;
const [page, setPage] = useState(1);
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
useEffect(() => { setPage(1); }, [open]);
useEffect(() => { setPage(1); }, [open, tagFilter]);
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -222,6 +236,24 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
</button>
</div>
{tagOptions.length > 0 && (
<div className="actorlib-tag-bar" role="toolbar" aria-label="按标签筛选" style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
<button
type="button"
className={`btn btn-sm${tagFilter.length === 0 ? " btn-primary" : ""}`}
onClick={() => setTagFilter([])}
></button>
{tagOptions.slice(0, 24).map((tag) => (
<button
key={tag.name}
type="button"
className={`btn btn-sm${tagFilter.includes(tag.name) ? " btn-primary" : ""}`}
onClick={() => toggleTag(tag.name)}
title={`${tag.count}`}
>{tag.label || tag.name}<span className="mono" style={{ marginLeft: 4, opacity: 0.65 }}>{tag.count}</span></button>
))}
</div>
)}
{list.length ? (
<>
<div className="actorlib-grid">
@@ -10,6 +10,8 @@ export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
/** 临时只开放 ≤60 秒;90/120/180 长视频入口先隐藏。 */
export const OMNI_MAX_VIDEO_DURATION = 60;
export const OMNI_VIDEO_DURATIONS = [
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒",
@@ -84,20 +86,22 @@ function catalogModelLabels(
}
function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean {
// 3160 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这总时长。
// 3160 秒总时长会拆成 30 秒片段(长视频 >60 秒入口已临时关闭)
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这总时长。
return modelDurations(config).some((seconds) => seconds >= 30)
|| /seedance\s*2\.5/i.test(model || "");
}
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
const seconds = modelDurations(config);
const seconds = modelDurations(config).filter((n) => n <= OMNI_MAX_VIDEO_DURATION);
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
const segmented = canCreateSegmentedVideo(config, model) ? [45, 60] : [];
const segmented = canCreateSegmentedVideo(config, model)
? [45, 60].filter((n) => n <= OMNI_MAX_VIDEO_DURATION)
: [];
return ["智能时长", ...seconds, ...segmented]
.filter((seconds, index, values) => values.indexOf(seconds) === index)
.map((seconds) => typeof seconds === "number" ? `${seconds}` : seconds);
.filter((value, index, values) => values.indexOf(value) === index)
.map((value) => typeof value === "number" ? `${value}` : value);
}
export function OmniParamBar({
@@ -198,8 +202,13 @@ export function OmniParamBar({
}
const nextDur = durationLabelsForModel(selected, model, true);
const seconds = Number(String(duration || "").replace(/\D/g, ""));
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
if (seconds > OMNI_MAX_VIDEO_DURATION) {
onDuration(`${OMNI_MAX_VIDEO_DURATION}`);
return;
}
const isPlannedSegmentedDuration = seconds > 30 && seconds <= OMNI_MAX_VIDEO_DURATION && canCreateSegmentedVideo(selected, model);
// 31–60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就偷偷改回 8 秒/智能时长。
if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
}