feat: publish admin sticker releases (TASK-WP5-05)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 30s

This commit is contained in:
suyx
2026-08-03 19:43:10 +08:00
parent f1bebab611
commit c92a91a127
21 changed files with 1755 additions and 18 deletions
+191
View File
@@ -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>;
}