/** * # 路由 /admin/media 媒体库 * 表格 + 缩略图预览;编辑替代文本(alt)与标题。 */ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography, Upload } from "antd"; import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons"; import { File as FileIcon } from "lucide-react"; import { useState } from "react"; import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin"; import { useAdminT } from "~/admin-i18n"; import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import type { media } from "~/db/schema"; export const Route = createFileRoute("/admin/media")({ validateSearch: (search: Record): AdminSearch => { // 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向) const out: AdminSearch = {}; const page = Math.floor(Number(search.page)) || 0; if (page > 1) out.page = page; if (typeof search.q === "string" && search.q) out.q = search.q; return out; }, loaderDeps: ({ search }) => ({ search }), loader: ({ deps }) => guardAdmin(() => adminListMedia({ data: { page: deps.search.page, q: deps.search.q || undefined } })), component: MediaPage, }); /** # 成员变量 媒体行类型(来自 media 表结构推断) **/ type MediaRow = typeof media.$inferSelect; /** # 函数 媒体库页面组件 */ function MediaPage() { const t = useAdminT(); const data = Route.useLoaderData(); const search = Route.useSearch(); const navigate = Route.useNavigate(); const router = useRouter(); const { message } = App.useApp(); const [q, setQ] = useState(search.q ?? ""); /** # 成员变量 当前编辑的媒体记录(null = 关闭弹窗) **/ const [editing, setEditing] = useState(null); const [form] = Form.useForm(); if (!data) return ; /** # 函数 更新查询参数 */ function updateSearch(patch: AdminSearch) { void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) }); } /** # 函数 打开编辑弹窗并回填表单 */ function openEdit(row: MediaRow) { setEditing(row); form.setFieldsValue({ altText: row.altText, title: row.title }); } /** # 函数 保存媒体 alt/title */ async function handleSave(values: { altText?: string; title?: string }) { if (!editing) return; await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } }); message.success(t("已保存")); setEditing(null); await router.invalidate(); } /** # 函数 文件大小格式化(字节 → KB/MB) */ function fmtSize(n: number | null): string { if (!n) return "-"; if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${Math.max(1, Math.round(n / 1024))} KB`; } return ( } value={q} onChange={(e) => setQ(e.target.value)} onPressEnter={() => updateSearch({ q })} style={{ width: 240 }} /> {/* 上传走 base64 JSON(server fn RPC),单文件 ≤20MB;成功后刷新列表 */} { try { const f = file as File; const buf = new Uint8Array(await f.arrayBuffer()); // 分块 btoa:直接 btoa(二进制串) 在大文件下栈溢出,按 0x8000 分块 let bin = ""; const CHUNK = 0x8000; for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)); const res = await adminUploadMedia({ data: { filename: f.name, mime: f.type || "", dataBase64: btoa(bin) } }); message.success(t("已上传 {name}(#{id})", { name: f.name, id: res.id })); onSuccess?.(res); await router.invalidate(); } catch (e) { message.error(e instanceof Error ? e.message : t("上传失败")); onError?.(e as Error); } }} > } /> }} pagination={tablePagination(data.total, data.page, data.pageSize, (p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }), )} columns={[ { title: "ID", dataIndex: "id", width: 70 }, { title: t("预览"), dataIndex: "url", width: 88, render: (v: string, r) => r.mimeType.startsWith("image") ? ( ) : ( ), }, { title: t("路径"), dataIndex: "path", render: (v: string, r) => ( {v} ), }, { title: t("类型"), dataIndex: "mimeType", width: 140 }, { title: t("尺寸"), key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") }, { title: t("大小"), dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) }, { title: t("引用数"), dataIndex: "refCount", width: 70 }, { title: t("替代文本"), dataIndex: "altText", ellipsis: true }, { title: t("上传时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("操作"), key: "actions", width: 70, render: (_, r) => (