v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* # 路由 /admin/media 媒体库
|
||||
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography } from "antd";
|
||||
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { File as FileIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminListMedia, adminUpdateMedia } from "~/server/admin";
|
||||
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 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("已保存");
|
||||
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="媒体库"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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: "预览",
|
||||
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="#bfbfbf" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "路径",
|
||||
dataIndex: "path",
|
||||
render: (v: string, r) => (
|
||||
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
|
||||
{v}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{ title: "类型", dataIndex: "mimeType", width: 140 },
|
||||
{ title: "尺寸", key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") },
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) },
|
||||
{ title: "引用", dataIndex: "refCount", width: 70 },
|
||||
{ title: "替代文本", dataIndex: "altText", ellipsis: true },
|
||||
{ title: "上传时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 70,
|
||||
render: (_, r) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal title={`编辑媒体 #${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="替代文本(alt)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user