v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user