Files
dusuniot/src/routes/admin.inquiries.tsx
T

176 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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>
);
}