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(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(); const [assets, setAssets] = useState(); const [loadingFailed, setLoadingFailed] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(""); const [file, setFile] = useState(); const [stableId, setStableId] = useState("STK1408"); const [part, setPart] = useState(25); const [order, setOrder] = useState(184); const [enabled, setEnabled] = useState(true); const [orderDrafts, setOrderDrafts] = useState>({}); async function load() { setLoadingFailed(false); try { const [sessionResult, assetsResult] = await Promise.all([ loadJson("/api/v1/admin-auth/session"), loadJson("/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
DADA ADMIN

ASSET OPERATIONS

普通贴纸

{assets?.release_version ?? "尚未发布"}
{!assets && !loadingFailed ?
: null} {loadingFailed ?

素材状态暂时无法读取。

: null} {assets ? <>
后台贴纸{assets.count} 受管内容{bytesLabel(assets.storage.managed_content_bytes)} / {bytesLabel(assets.storage.hard_limit_bytes)} GB 存储状态{assets.storage.storage_status}

上传并发布

PNG / WebP
{uploadBlocked ?

当前存储状态禁止新增原图和缩略图。

: null}

当前版本

{assets.count} 项
{assets.items.length === 0 ?

当前没有后台上传的普通贴纸。

:
{assets.items.map((item) => )}
预览稳定 IDPart / 顺序原文件尺寸文件状态发布状态操作
{item.stable_id}{item.resource_version} part{item.part} setOrderDrafts((current) => ({ ...current, [item.stable_id]: Number(event.target.value) }))} type="number" value={orderDrafts[item.stable_id] ?? item.order} /> {item.original_filename}{item.mime_type} · {item.original_byte_size.toLocaleString("zh-CN")} B {item.width} x {item.height} {item.file_state} {item.enabled ? "已启用" : "已停用"}
}
{notice ?

{notice}

: null} : null}
; }