v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+147
View File
@@ -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>
);
}