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,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>;
|
||||
}
|
||||
Reference in New Issue
Block a user