大量修改二期功能清单内容
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user