176 lines
7.6 KiB
TypeScript
176 lines
7.6 KiB
TypeScript
/**
|
||
* # 路由 /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>
|
||
);
|
||
}
|