feat: publish admin sticker releases (TASK-WP5-05)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 30s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 30s
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
.admin-assets-page { min-height: 100vh; color: #111111; background: #f6f6f4; }
|
||||
.admin-assets-page > main { width: min(1360px, calc(100% - 64px)); margin: 0 auto; padding: 36px 0 80px; }
|
||||
.admin-assets-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid #999993; }
|
||||
.admin-assets-heading p { margin: 0 0 4px; font: 700 11px Consolas, monospace; }
|
||||
.admin-assets-heading h1 { margin: 0; font-size: 34px; }
|
||||
.admin-assets-heading > strong { font: 700 13px Consolas, monospace; }
|
||||
.admin-assets-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 22px 0; border-block: 1px solid #8c8c86; background: #ffffff; }
|
||||
.admin-assets-summary > span { display: grid; min-width: 0; gap: 6px; padding: 17px 18px; border-right: 1px solid #c1c1ba; color: #65655f; font-size: 12px; }
|
||||
.admin-assets-summary > span:last-child { border-right: 0; }
|
||||
.admin-assets-summary strong { color: #111111; font-size: 15px; overflow-wrap: anywhere; }
|
||||
.admin-assets-summary .is-active { color: #1f6639; }
|
||||
.admin-assets-summary .is-full,
|
||||
.admin-assets-summary .is-unavailable { color: #9b2c23; }
|
||||
.admin-assets-upload,
|
||||
.admin-assets-list { margin-top: 22px; border-block: 1px solid #8c8c86; background: #ffffff; }
|
||||
.admin-assets-upload > header,
|
||||
.admin-assets-list > header { display: flex; align-items: center; justify-content: space-between; min-height: 54px; padding: 0 16px; border-bottom: 1px solid #c1c1ba; background: #e7e7e2; }
|
||||
.admin-assets-upload h2,
|
||||
.admin-assets-list h2 { margin: 0; font-size: 16px; }
|
||||
.admin-assets-upload header span,
|
||||
.admin-assets-list header span { font: 700 11px Consolas, monospace; }
|
||||
.admin-assets-form { display: grid; grid-template-columns: minmax(230px, 2fr) minmax(130px, 1fr) 84px 92px 130px auto; align-items: end; gap: 12px; padding: 18px 16px; }
|
||||
.admin-assets-form label { display: grid; gap: 6px; min-width: 0; color: #4c4c47; font-size: 11px; font-weight: 800; }
|
||||
.admin-assets-form input { width: 100%; min-height: 40px; padding: 7px 9px; border: 1px solid #777770; border-radius: 0; background: #ffffff; }
|
||||
.admin-assets-form input[type="file"] { padding: 7px; }
|
||||
.admin-assets-form .admin-assets-enabled { display: flex; min-height: 40px; align-items: center; gap: 8px; color: #111111; }
|
||||
.admin-assets-enabled input { width: 18px; min-height: 18px; }
|
||||
.admin-assets-form button,
|
||||
.admin-assets-alert button { min-height: 42px; padding: 9px 14px; border: 1px solid #111111; border-radius: 0; background: #f2f500; font-weight: 900; }
|
||||
.admin-assets-form button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; }
|
||||
.admin-assets-blocked { margin: 0; padding: 12px 16px; border-top: 1px solid #e2b8b3; color: #812219; background: #fff1ef; font-weight: 700; }
|
||||
.admin-assets-table-wrap { overflow-x: auto; }
|
||||
.admin-assets-table-wrap table { width: 100%; min-width: 1120px; border-collapse: collapse; table-layout: fixed; }
|
||||
.admin-assets-table-wrap th,
|
||||
.admin-assets-table-wrap td { padding: 12px 10px; border-right: 1px solid #d0d0ca; border-bottom: 1px solid #d0d0ca; text-align: left; vertical-align: middle; font-size: 12px; }
|
||||
.admin-assets-table-wrap thead th { background: #f1f1ed; font-weight: 900; }
|
||||
.admin-assets-table-wrap th:first-child { width: 78px; }
|
||||
.admin-assets-table-wrap th:nth-child(2) { width: 150px; }
|
||||
.admin-assets-table-wrap th:nth-child(3) { width: 140px; }
|
||||
.admin-assets-table-wrap th:nth-child(4) { width: 210px; }
|
||||
.admin-assets-table-wrap th:nth-child(5) { width: 92px; }
|
||||
.admin-assets-table-wrap th:nth-child(6),
|
||||
.admin-assets-table-wrap th:nth-child(7) { width: 92px; }
|
||||
.admin-assets-table-wrap th:last-child { width: 180px; }
|
||||
.admin-assets-table-wrap img { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #c1c1ba; background: #f6f6f4; }
|
||||
.admin-assets-table-wrap strong,
|
||||
.admin-assets-table-wrap small { display: block; }
|
||||
.admin-assets-table-wrap small { margin-top: 4px; color: #65655f; font-size: 10px; overflow-wrap: anywhere; }
|
||||
.admin-assets-table-wrap input[type="number"] { width: 70px; min-height: 34px; margin-top: 5px; padding: 5px 7px; border: 1px solid #777770; border-radius: 0; }
|
||||
.admin-assets-table-wrap td:last-child { display: flex; gap: 6px; }
|
||||
.admin-assets-table-wrap button { min-height: 34px; padding: 6px 8px; border: 1px solid #555550; border-radius: 0; background: #ffffff; font-weight: 800; }
|
||||
.admin-assets-table-wrap button:disabled { color: #8a8a84; background: #ecece8; }
|
||||
.admin-assets-table-wrap .is-enabled { color: #1f6639; font-weight: 800; }
|
||||
.admin-assets-table-wrap .is-disabled { color: #812219; font-weight: 800; }
|
||||
.admin-assets-empty { margin: 0; padding: 34px 16px; color: #65655f; }
|
||||
.admin-assets-notice { margin: 16px 0 0; padding: 13px 16px; border-left: 4px solid #287b45; background: #edf8f0; font-weight: 800; }
|
||||
.admin-assets-alert { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 24px; padding: 16px; border-left: 5px solid #d14a3b; background: #fff1ef; }
|
||||
.admin-assets-loading { display: grid; gap: 10px; margin-top: 24px; }
|
||||
.admin-assets-loading span { display: block; height: 62px; background: #dfdfda; }
|
||||
@media (max-width: 900px) {
|
||||
.admin-assets-page > main { width: 100%; padding-right: 16px; padding-left: 16px; }
|
||||
.admin-assets-summary { grid-template-columns: 1fr; }
|
||||
.admin-assets-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; }
|
||||
.admin-assets-form { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
.admin-product-header { padding: 0 12px; overflow-x: auto; }
|
||||
.admin-product-header nav a { min-width: 66px; }
|
||||
.admin-assets-form { grid-template-columns: 1fr; }
|
||||
.admin-assets-heading { align-items: start; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import "./admin-assets.css";
|
||||
|
||||
interface AdminSession { csrf_token: string }
|
||||
interface StorageState {
|
||||
capacity_notice_level: "normal" | "warning" | "critical";
|
||||
hard_limit_bytes: number;
|
||||
managed_content_bytes: number;
|
||||
storage_status: "active" | "full" | "unavailable";
|
||||
}
|
||||
interface AdminSticker {
|
||||
enabled: boolean;
|
||||
file_state: "committed";
|
||||
height: number;
|
||||
mime_type: "image/png" | "image/webp";
|
||||
order: number;
|
||||
original_byte_size: number;
|
||||
original_filename: string;
|
||||
part: number;
|
||||
resource_version: string;
|
||||
stable_id: string;
|
||||
thumbnail_byte_size: number;
|
||||
thumbnail_reference: { url: string };
|
||||
width: number;
|
||||
}
|
||||
interface AdminAssetsResponse {
|
||||
count: number;
|
||||
items: AdminSticker[];
|
||||
release_version: string | null;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
function bytesLabel(bytes: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(bytes / (1024 ** 3));
|
||||
}
|
||||
|
||||
async function loadJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||
const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() as T : undefined;
|
||||
return { body, response };
|
||||
}
|
||||
|
||||
export function AdminAssetsPage() {
|
||||
const [session, setSession] = useState<AdminSession>();
|
||||
const [assets, setAssets] = useState<AdminAssetsResponse>();
|
||||
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [file, setFile] = useState<File>();
|
||||
const [stableId, setStableId] = useState("STK1408");
|
||||
const [part, setPart] = useState(25);
|
||||
const [order, setOrder] = useState(184);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [orderDrafts, setOrderDrafts] = useState<Record<string, number>>({});
|
||||
|
||||
async function load() {
|
||||
setLoadingFailed(false);
|
||||
try {
|
||||
const [sessionResult, assetsResult] = await Promise.all([
|
||||
loadJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||
loadJson<AdminAssetsResponse>("/api/v1/admin/assets/static-stickers"),
|
||||
]);
|
||||
if (!sessionResult.response.ok || !assetsResult.response.ok || !sessionResult.body || !assetsResult.body) throw new Error("load_failed");
|
||||
setSession(sessionResult.body);
|
||||
setAssets(assetsResult.body);
|
||||
setOrderDrafts(Object.fromEntries(assetsResult.body.items.map((item) => [item.stable_id, item.order])));
|
||||
const numericIds = assetsResult.body.items.map((item) => Number(item.stable_id.slice(3))).filter(Number.isFinite);
|
||||
setStableId(`STK${Math.max(1407, ...numericIds) + 1}`);
|
||||
setOrder(Math.max(183, ...assetsResult.body.items.filter((item) => item.part === 25).map((item) => item.order)) + 1);
|
||||
setNotice("");
|
||||
} catch {
|
||||
setLoadingFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const uploadBlocked = !assets || assets.storage.storage_status !== "active";
|
||||
const formValid = useMemo(() => Boolean(
|
||||
file && /^(image\/png|image\/webp)$/.test(file.type) && /^STK[0-9]{4,}$/.test(stableId)
|
||||
&& Number.isSafeInteger(part) && part >= 1 && part <= 25 && Number.isSafeInteger(order) && order > 0,
|
||||
), [file, order, part, stableId]);
|
||||
|
||||
async function upload() {
|
||||
if (!file || !session || !formValid || uploadBlocked || busy) return;
|
||||
setBusy(true);
|
||||
setNotice("");
|
||||
try {
|
||||
const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const form = new FormData();
|
||||
form.append("stable_id", stableId);
|
||||
form.append("part", String(part));
|
||||
form.append("order", String(order));
|
||||
form.append("enabled", String(enabled));
|
||||
form.append("original_byte_size", String(file.size));
|
||||
form.append("original_sha256", sha256);
|
||||
form.append("sticker_file", file, file.name);
|
||||
const response = await fetch("/api/v1/admin/assets/static-stickers", {
|
||||
body: form,
|
||||
credentials: "same-origin",
|
||||
headers: { "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
setNotice(response.status === 507 ? "存储容量已满或暂不可用,未写入任何文件。" : response.status === 409 ? "稳定 ID 或 part 顺序已存在。" : "文件格式、内容或字段校验未通过。");
|
||||
return;
|
||||
}
|
||||
setFile(undefined);
|
||||
await load();
|
||||
setNotice("贴纸已生成缩略图并发布新资源版本。");
|
||||
} catch {
|
||||
setNotice("上传未完成,未发布新资源版本。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(item: AdminSticker, change: { enabled?: boolean; order?: number }) {
|
||||
if (!session || busy) return;
|
||||
setBusy(true);
|
||||
setNotice("");
|
||||
try {
|
||||
const { response } = await loadJson(`/api/v1/admin/assets/static-stickers/${encodeURIComponent(item.stable_id)}`, {
|
||||
body: JSON.stringify(change),
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) throw new Error("update_failed");
|
||||
await load();
|
||||
setNotice(change.enabled === false ? "贴纸已停用,新项目目录不再显示。" : change.enabled === true ? "贴纸已重新启用。" : "part 顺序已发布到新资源版本。");
|
||||
} catch {
|
||||
setNotice("素材状态未更新。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="admin-assets-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a href="/admin/users">用户</a><a href="/admin/models">模型</a><a aria-current="page" href="/admin/assets">素材</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-assets-heading"><div><p>ASSET OPERATIONS</p><h1>普通贴纸</h1></div><strong>{assets?.release_version ?? "尚未发布"}</strong></header>
|
||||
{!assets && !loadingFailed ? <div aria-label="贴纸素材加载中" className="admin-assets-loading"><span /><span /><span /></div> : null}
|
||||
{loadingFailed ? <p className="admin-assets-alert" role="alert">素材状态暂时无法读取。<button onClick={() => void load()} type="button">重试</button></p> : null}
|
||||
{assets ? <>
|
||||
<div className="admin-assets-summary" aria-label="素材存储摘要">
|
||||
<span>后台贴纸<strong>{assets.count}</strong></span>
|
||||
<span>受管内容<strong>{bytesLabel(assets.storage.managed_content_bytes)} / {bytesLabel(assets.storage.hard_limit_bytes)} GB</strong></span>
|
||||
<span>存储状态<strong className={`is-${assets.storage.storage_status}`}>{assets.storage.storage_status}</strong></span>
|
||||
</div>
|
||||
<section className="admin-assets-upload" aria-labelledby="asset-upload-title">
|
||||
<header><h2 id="asset-upload-title">上传并发布</h2><span>PNG / WebP</span></header>
|
||||
<div className="admin-assets-form">
|
||||
<label>文件<input accept="image/png,image/webp" aria-label="贴纸文件" disabled={uploadBlocked || busy} key={file?.name ?? "empty"} onChange={(event) => setFile(event.target.files?.[0])} type="file" /></label>
|
||||
<label>稳定 ID<input aria-label="稳定 ID" disabled={uploadBlocked || busy} onChange={(event) => setStableId(event.target.value.toUpperCase())} value={stableId} /></label>
|
||||
<label>Part<input aria-label="Part" disabled={uploadBlocked || busy} max="25" min="1" onChange={(event) => setPart(Number(event.target.value))} type="number" value={part} /></label>
|
||||
<label>顺序<input aria-label="顺序" disabled={uploadBlocked || busy} min="1" onChange={(event) => setOrder(Number(event.target.value))} type="number" value={order} /></label>
|
||||
<label className="admin-assets-enabled"><input checked={enabled} disabled={uploadBlocked || busy} onChange={(event) => setEnabled(event.target.checked)} type="checkbox" />发布后启用</label>
|
||||
<button disabled={!formValid || uploadBlocked || busy} onClick={() => void upload()} type="button">{busy ? "处理中" : "上传并发布"}</button>
|
||||
</div>
|
||||
{uploadBlocked ? <p className="admin-assets-blocked" role="status">当前存储状态禁止新增原图和缩略图。</p> : null}
|
||||
</section>
|
||||
<section className="admin-assets-list" aria-labelledby="asset-list-title">
|
||||
<header><h2 id="asset-list-title">当前版本</h2><span>{assets.count} 项</span></header>
|
||||
{assets.items.length === 0 ? <p className="admin-assets-empty">当前没有后台上传的普通贴纸。</p> : <div className="admin-assets-table-wrap"><table>
|
||||
<thead><tr><th>预览</th><th>稳定 ID</th><th>Part / 顺序</th><th>原文件</th><th>尺寸</th><th>文件状态</th><th>发布状态</th><th>操作</th></tr></thead>
|
||||
<tbody>{assets.items.map((item) => <tr key={item.stable_id}>
|
||||
<td><img alt="" src={item.thumbnail_reference.url} /></td>
|
||||
<th scope="row"><strong>{item.stable_id}</strong><small>{item.resource_version}</small></th>
|
||||
<td><span>part{item.part}</span><input aria-label={`${item.stable_id} 顺序`} min="1" onChange={(event) => setOrderDrafts((current) => ({ ...current, [item.stable_id]: Number(event.target.value) }))} type="number" value={orderDrafts[item.stable_id] ?? item.order} /></td>
|
||||
<td><span>{item.original_filename}</span><small>{item.mime_type} · {item.original_byte_size.toLocaleString("zh-CN")} B</small></td>
|
||||
<td>{item.width} x {item.height}</td>
|
||||
<td>{item.file_state}</td>
|
||||
<td><span className={item.enabled ? "is-enabled" : "is-disabled"}>{item.enabled ? "已启用" : "已停用"}</span></td>
|
||||
<td><button disabled={busy || (orderDrafts[item.stable_id] ?? item.order) === item.order} onClick={() => void update(item, { order: orderDrafts[item.stable_id] ?? item.order })} type="button">更新顺序</button><button disabled={busy} onClick={() => void update(item, { enabled: !item.enabled })} type="button">{item.enabled ? "停用" : "启用"}</button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
</section>
|
||||
{notice ? <p className="admin-assets-notice" role="status">{notice}</p> : null}
|
||||
</> : null}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export function AdminModelsPage() {
|
||||
<div className="admin-models-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a href="/admin/users">用户</a><a aria-current="page" href="/admin/models">模型</a><a href="/admin/audit">审计</a></nav>
|
||||
<nav aria-label="后台导航"><a href="/admin/users">用户</a><a aria-current="page" href="/admin/models">模型</a><a href="/admin/assets">素材</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-models-heading">
|
||||
|
||||
@@ -102,7 +102,7 @@ export function AdminUsersPage() {
|
||||
<div className="admin-users-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users">用户</a><a href="/admin/models">模型</a><a href="/admin/audit">审计</a></nav>
|
||||
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users">用户</a><a href="/admin/models">模型</a><a href="/admin/assets">素材</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-users-heading">
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
type DynamicTemplateId,
|
||||
} from "./dynamic-provider.js";
|
||||
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
|
||||
import { P0A_STATIC_STICKER_CATALOG, P0A_STATIC_STICKER_COUNT, stickerWindow } from "./static-sticker-catalog.js";
|
||||
import { P0A_STATIC_STICKER_CATALOG, stickerWindow, type StaticStickerCatalogItem } from "./static-sticker-catalog.js";
|
||||
import {
|
||||
createColorCardElement,
|
||||
extractPaletteFromImage,
|
||||
@@ -136,6 +136,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
|
||||
const [stickerScrollTop, setStickerScrollTop] = useState(0);
|
||||
const [stickerCatalog, setStickerCatalog] = useState<StaticStickerCatalogItem[]>(P0A_STATIC_STICKER_CATALOG);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [guides, setGuides] = useState<string[]>([]);
|
||||
const [multiMode, setMultiMode] = useState(false);
|
||||
@@ -187,6 +188,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
return () => { active = false; };
|
||||
}, [session?.user.user_id]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
readEditorJson<{ items: StaticStickerCatalogItem[] }>("/api/v1/static-stickers/current")
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const uploaded = response.items.filter((item) => item.enabled && item.origin === "admin_uploaded");
|
||||
setStickerCatalog([...P0A_STATIC_STICKER_CATALOG, ...uploaded].sort((left, right) => left.part - right.part || left.order - right.order || left.stable_id.localeCompare(right.stable_id)));
|
||||
})
|
||||
.catch(() => { if (active) setStickerCatalog(P0A_STATIC_STICKER_CATALOG); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !session || !canvasState) return undefined;
|
||||
const queue = new ProjectAutoSaveQueue({
|
||||
@@ -343,15 +356,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
setNotice(message);
|
||||
}
|
||||
|
||||
function addSticker(assetId: string) {
|
||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller || !canvasState) return;
|
||||
try {
|
||||
controller.add(createStaticStickerElement({
|
||||
assetId,
|
||||
assetId: sticker.stable_id,
|
||||
identity: newElementIdentity(),
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resourceVersion: "fixture-v1",
|
||||
resourceVersion: sticker.resource_version,
|
||||
zIndex: canvasState.elements.length,
|
||||
}));
|
||||
commitElementOperation(controller, "贴纸已加入画布");
|
||||
@@ -832,11 +845,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
{...(templateCategory ? { category: templateCategory } : {})}
|
||||
/> : null}
|
||||
{activePanel === "stickers" ? (() => {
|
||||
const visibleStickers = stickerWindow(P0A_STATIC_STICKER_CATALOG, stickerScrollTop, 280);
|
||||
return <section><h2>普通贴纸</h2><p aria-live="polite" className="editor-sticker-count">共 {P0A_STATIC_STICKER_COUNT.toLocaleString("zh-CN")} 张</p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
|
||||
const visibleStickers = stickerWindow(stickerCatalog, stickerScrollTop, 280);
|
||||
return <section><h2>普通贴纸</h2><p aria-live="polite" className="editor-sticker-count">共 {stickerCatalog.length.toLocaleString("zh-CN")} 张</p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
|
||||
<div style={{ paddingTop: visibleStickers.top_spacer_px, paddingBottom: visibleStickers.bottom_spacer_px }}>
|
||||
<div className="editor-sticker-grid">
|
||||
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker.stable_id)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
|
||||
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { UserAuthPage } from "./user-auth.js";
|
||||
import { AccountSettingsPage } from "./account-settings.js";
|
||||
import { AdminUsersPage } from "./admin-users.js";
|
||||
import { AdminModelsPage } from "./admin-models.js";
|
||||
import { AdminAssetsPage } from "./admin-assets.js";
|
||||
import { CreditsPage } from "./credits-page.js";
|
||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||
import { EditorPage } from "./editor-page.js";
|
||||
@@ -36,6 +37,7 @@ function renderAuthenticationEntry() {
|
||||
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/models") authenticationPage = <AdminModelsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/assets") authenticationPage = <AdminAssetsPage key={authRevision} />;
|
||||
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
|
||||
else authenticationPage = <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
|
||||
@@ -45,3 +45,4 @@ export function stickerWindow(items: readonly StaticStickerCatalogItem[], scroll
|
||||
}
|
||||
|
||||
export { staticStickerOriginalUrl, staticStickerThumbnailUrl };
|
||||
export type { StaticStickerCatalogItem };
|
||||
|
||||
Reference in New Issue
Block a user