模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
// 31–60 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这个总时长。
|
||||
// 31–60 秒总时长会拆成 ≤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 !== "智能时长") || "智能时长"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user