178 lines
7.2 KiB
TypeScript
178 lines
7.2 KiB
TypeScript
/**
|
||
* # 路由 /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<string, unknown>): 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<MediaRow | null>(null);
|
||
const [form] = Form.useForm();
|
||
|
||
if (!data) return <Forbidden />;
|
||
|
||
/** # 函数 更新查询参数 */
|
||
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 (
|
||
<Card size="small">
|
||
<PageHeader
|
||
title={t("媒体库")}
|
||
extra={
|
||
<Space>
|
||
<Input
|
||
allowClear
|
||
placeholder={t("搜索文件路径")}
|
||
prefix={<SearchOutlined aria-hidden />}
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
onPressEnter={() => updateSearch({ q })}
|
||
style={{ width: 240 }}
|
||
/>
|
||
{/* 上传走 base64 JSON(server fn RPC),单文件 ≤20MB;成功后刷新列表 */}
|
||
<Upload
|
||
multiple
|
||
showUploadList={false}
|
||
customRequest={async ({ file, onSuccess, onError }) => {
|
||
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);
|
||
}
|
||
}}
|
||
>
|
||
<Button type="primary" icon={<UploadOutlined aria-hidden />}>
|
||
{t("上传媒体")}
|
||
</Button>
|
||
</Upload>
|
||
</Space>
|
||
}
|
||
/>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
dataSource={data.items}
|
||
locale={{ emptyText: <AdminEmpty /> }}
|
||
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") ? (
|
||
<Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||
) : (
|
||
<FileIcon size={36} aria-hidden color="var(--ant-color-text-quaternary)" />
|
||
),
|
||
},
|
||
{
|
||
title: t("路径"),
|
||
dataIndex: "path",
|
||
render: (v: string, r) => (
|
||
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
|
||
{v}
|
||
</Typography.Link>
|
||
),
|
||
},
|
||
{ 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) => (
|
||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Modal title={t("编辑媒体 #{id}", { id: editing?.id ?? "" })} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
|
||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||
<Form.Item name="altText" label={t("替代文本(alt)")}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="title" label={t("标题")}>
|
||
<Input />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</Card>
|
||
);
|
||
}
|