Files
dusuniot/src/routes/admin.media.tsx
T

178 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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>
);
}