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
+175
View File
@@ -0,0 +1,175 @@
/**
* # 路由 /admin/inquiries 询盘管理
* 状态 Tab 过滤 + 表格;行点击抽屉查看完整 fields JSON;操作按钮标记已读/已回复/垃圾。
* 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Descriptions, Drawer, Input, Space, Table, Tabs, Tag } from "antd";
import { CheckOutlined, SearchOutlined, SendOutlined, StopOutlined } from "@ant-design/icons";
import { useState } from "react";
import { listInquiries, updateInquiryStatus } from "~/server/inquiries";
import type { inquiries } from "~/db/schema";
import {
AdminEmpty,
Forbidden,
INQUIRY_STATUS_COLOR,
INQUIRY_STATUS_LABEL,
Json,
PageHeader,
StatusTag,
fmtDateTime,
guardAdmin,
tablePagination,
type AdminSearch,
} from "~/components/admin/common";
export const Route = createFileRoute("/admin/inquiries")({
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;
if (typeof search.status === "string" && search.status) out.status = search.status;
return out;
},
loaderDeps: ({ search }) => ({ search }),
loader: ({ deps }) =>
guardAdmin(() =>
listInquiries({ data: { page: deps.search.page, status: deps.search.status || undefined, q: deps.search.q || undefined } }),
),
component: InquiriesPage,
});
/** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/
type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json };
/** # 函数 询盘管理页面组件 */
function InquiriesPage() {
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 [viewing, setViewing] = useState<InquiryRow | null>(null);
if (!data) return <Forbidden />;
/** # 函数 更新查询参数 */
function updateSearch(patch: AdminSearch) {
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
}
/** # 函数 更新询盘状态并刷新 */
async function handleStatus(id: number, status: string) {
await updateInquiryStatus({ data: { id, status } });
message.success("状态已更新");
await router.invalidate();
}
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 }}
/>
}
/>
<Tabs
activeKey={search.status ?? ""}
onChange={(key) => updateSearch({ status: key })}
items={[
{ key: "", label: "全部" },
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: INQUIRY_STATUS_LABEL[s] })),
]}
/>
<Table
rowKey="id"
size="small"
dataSource={data.items}
locale={{ emptyText: <AdminEmpty /> }}
onRow={(r) => ({ onClick: () => setViewing(r), style: { cursor: "pointer" } })}
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: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
{ title: "姓名", dataIndex: "name", width: 130, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
{ title: "国家", dataIndex: "country", width: 110, ellipsis: true },
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
{ title: "来源", dataIndex: "formName", width: 150, ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <StatusTag value={v} colorMap={INQUIRY_STATUS_COLOR} labelMap={INQUIRY_STATUS_LABEL} />,
},
{
title: "操作",
key: "actions",
width: 130,
render: (_, r) => (
<Space size={0} onClick={(e) => e.stopPropagation()}>
{r.status !== "read" && r.status !== "replied" && (
<Button size="small" type="text" icon={<CheckOutlined aria-label="标记已读" />} title="标记已读" onClick={() => void handleStatus(r.id, "read")} />
)}
{r.status !== "replied" && (
<Button size="small" type="text" icon={<SendOutlined aria-label="标记已回复" />} title="标记已回复" onClick={() => void handleStatus(r.id, "replied")} />
)}
{r.status !== "spam" && (
<Button size="small" type="text" danger icon={<StopOutlined aria-label="标记垃圾" />} title="标记垃圾" onClick={() => void handleStatus(r.id, "spam")} />
)}
</Space>
),
},
]}
/>
<Drawer
title={viewing ? `询盘 #${viewing.id} · ${viewing.name || viewing.email}` : ""}
width={560}
open={viewing !== null}
onClose={() => setViewing(null)}
destroyOnHidden
>
{viewing && (
<>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="状态">
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
<Descriptions.Item label="姓名">{viewing.name || "-"}</Descriptions.Item>
<Descriptions.Item label="邮箱">{viewing.email || "-"}</Descriptions.Item>
<Descriptions.Item label="电话">{viewing.phone || "-"}</Descriptions.Item>
<Descriptions.Item label="公司">{viewing.company || "-"}</Descriptions.Item>
<Descriptions.Item label="国家">{viewing.country || "-"}</Descriptions.Item>
<Descriptions.Item label="感兴趣产品">{viewing.productInterest || "-"}</Descriptions.Item>
<Descriptions.Item label="来源">{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
<Descriptions.Item label="来源页面">{viewing.sourcePage || "-"}</Descriptions.Item>
<Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item>
<Descriptions.Item label="询盘内容">{viewing.message || "-"}</Descriptions.Item>
</Descriptions>
<h4 style={{ margin: "16px 0 8px" }}>完整字段(fields JSON)</h4>
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 6, fontSize: 12, maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(viewing.fields, null, 2)}
</pre>
</>
)}
</Drawer>
</Card>
);
}